Files
mdm-ordinis/ordinis-admin-ui/src/components/layout/Sidebar.tsx
T
2026-05-13 12:45:45 +00:00

583 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
type LucideIcon,
} from 'lucide-react'
import { useAuth } from 'react-oidc-context'
import { useCanMutate } from '@/auth/useCanMutate'
import {
useCanReviewRecord,
useCanReviewSchema,
} from '@/auth/usePermissions'
import {
useDictionaries,
useMyDrafts,
useMySchemaDrafts,
useOutboxStats,
useReviewsBadgeCount,
useWebhookSubscriptions,
} from '@/api/queries'
import { Sheet, SheetContent } from '@/ui'
import { ThemeSwitch } from '@/components/layout/ThemeSwitch'
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
}
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
const dictsQuery = useDictionaries()
// Loaded just for сидбар counter — light request, кэшируется.
// Badge "Мои черновики" суммирует record (/drafts/me) + schema
// (/admin/schema-drafts/me) — maker не должен помнить разные типы.
const myDrafts = useMyDrafts(0, 1)
const mySchemaDrafts = useMySchemaDrafts(0, 1)
// 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 гейтит, поэтому
// anonymous safe (просто 401 проигнорируется).
const outboxStats = useOutboxStats()
const webhooks = useWebhookSubscriptions()
const dictsCount = dictsQuery.data?.length ?? 0
const myDraftsCount =
(myDrafts.data?.totalElements ?? 0) + (mySchemaDrafts.data?.totalElements ?? 0)
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). Order: Главная →
// Справочники → Мои черновики → На ревью → Outbox → Webhooks → Аудит → Поиск.
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: '/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,
},
{
to: '/webhooks',
label: t('nav.webhooks'),
icon: Webhook,
badge: webhooksActive > 0 ? webhooksActive : undefined,
authRequired: true,
},
{ to: '/audit', label: t('nav.audit'), icon: ScrollText, authRequired: 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
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<boolean>(() => {
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 (
<aside
className={cn(
'hidden lg:flex shrink-0 flex-col border-r border-line bg-surface transition-[width] duration-200',
collapsed ? 'w-[56px]' : 'w-[220px]',
)}
>
<SidebarContent
sections={visibleSections}
auth={auth}
collapsed={collapsed}
onToggleCollapsed={toggleCollapsed}
/>
</aside>
)
}
/**
* SidebarContent — внутренности (logo + nav + user). Используется в desktop
* rail (Sidebar) И в mobile drawer (MobileSidebar) — DRY.
*/
function SidebarContent({
sections,
auth,
onNavigate,
collapsed = false,
onToggleCollapsed,
}: {
sections: NavSection[]
auth: ReturnType<typeof useAuth>
/** 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 */}
<div
className={cn(
'h-11 flex items-center border-b border-line shrink-0',
collapsed ? 'justify-center px-2' : 'px-5 gap-2',
)}
>
<Link
to="/"
className="flex items-center gap-2 hover:opacity-80 transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
onClick={onNavigate}
title="ORDINIS MDM"
>
{/* 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). */}
<svg
width="28"
height="28"
viewBox="0 0 256 256"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
className="shrink-0 text-accent"
>
<g transform="translate(128 128)">
{/* Outer orbits — line tone. Math: viewBox 256 при render 28px =
* 0.11px/unit. stroke=12 даёт ~1.3px real — чёткая видимая линия. */}
<circle r="96" fill="none" stroke="var(--color-line)" strokeWidth="12" />
<circle r="62" fill="none" stroke="var(--color-line)" strokeWidth="12" />
{/* Outer orbit dots (3) — large accent dots */}
<circle cx="96" cy="0" r="16" fill="currentColor" />
<circle cx="-48" cy="83.14" r="14" fill="currentColor" opacity="0.7" />
<circle cx="-48" cy="-83.14" r="14" fill="currentColor" opacity="0.7" />
{/* Inner orbit dots (3) */}
<circle cx="-62" cy="0" r="13" fill="currentColor" />
<circle cx="31" cy="-53.69" r="11" fill="currentColor" opacity="0.7" />
<circle cx="31" cy="53.69" r="11" fill="currentColor" opacity="0.7" />
{/* Core — solid accent, prominent */}
<circle r="24" fill="currentColor" />
</g>
</svg>
{!collapsed && (
<span className="font-display text-title-md leading-none tracking-[0.22em] text-ink inline-flex items-baseline gap-1.5">
ORDINIS
<span className="text-mute font-normal tracking-[0.32em] text-[0.78em]">MDM</span>
</span>
)}
</Link>
</div>
{/* Nav sections */}
<nav className="flex-1 overflow-y-auto py-3 px-2 flex flex-col gap-4">
{sections.map((section, idx) => (
<div key={idx} className="flex flex-col gap-0.5">
{section.label && !collapsed && (
<div className="text-cap px-3 py-1 text-mute">{section.label}</div>
)}
{section.items.map((item) => (
<SidebarLink key={item.to} {...item} onClick={onNavigate} collapsed={collapsed} />
))}
</div>
))}
</nav>
{/* Footer: Lang toggle + User profile (or Sign-in) + Collapse toggle.
* Moved from TopBar per user feedback: "вход в сайдбар, ru/en в сайдбаре". */}
<SidebarFooter
auth={auth}
collapsed={collapsed}
onToggleCollapsed={onToggleCollapsed}
/>
</>
)
}
function SidebarFooter({
auth,
collapsed,
onToggleCollapsed,
}: {
auth: ReturnType<typeof useAuth>
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 && (
<div className="border-t border-line px-3 py-2 flex items-center gap-2 shrink-0">
<button
type="button"
onClick={() => i18n.changeLanguage(next)}
className="h-7 px-2.5 rounded-md border border-line bg-surface text-cap text-ink-2 hover:bg-surface-2 hover:text-ink transition-colors"
aria-label={`Language: ${cur.toUpperCase()}, click to switch`}
>
{cur.toUpperCase()}
</button>
<div className="ml-auto">
<ThemeSwitch />
</div>
</div>
)}
{/* Auth block: avatar + name (authed) OR Sign-in CTA (anonymous). */}
{auth.isAuthenticated && auth.user?.profile ? (
<AuthedUserBlock auth={auth} collapsed={collapsed} />
) : (
<SignInCta auth={auth} collapsed={collapsed} />
)}
{/* Collapse toggle */}
{onToggleCollapsed && (
<button
type="button"
onClick={onToggleCollapsed}
aria-label={collapsed
? t('sidebar.expand', { defaultValue: 'Развернуть' })
: t('sidebar.collapse', { defaultValue: 'Свернуть' })
}
title={collapsed ? t('sidebar.expand', { defaultValue: 'Развернуть' }) : t('sidebar.collapse', { defaultValue: 'Свернуть' })}
className={cn(
'border-t border-line shrink-0 h-8 flex items-center gap-2 text-cap text-mute hover:text-ink hover:bg-surface-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
collapsed ? 'justify-center' : 'px-4',
)}
>
<span aria-hidden>{collapsed ? '' : ''}</span>
{!collapsed && (
<span>{t('sidebar.collapse', { defaultValue: 'Свернуть' })}</span>
)}
</button>
)}
</>
)
}
function AuthedUserBlock({
auth,
collapsed,
}: {
auth: ReturnType<typeof useAuth>
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 (
<div
className={cn(
'border-t border-line py-3 flex items-center gap-2 shrink-0',
collapsed ? 'px-2 justify-center' : 'px-3',
)}
title={display}
>
<div className="size-8 rounded-full bg-accent text-on-accent inline-flex items-center justify-center text-cell font-semibold shrink-0">
{display.charAt(0).toUpperCase()}
</div>
{!collapsed && (
<div className="min-w-0 flex-1">
<div className="text-cell font-medium text-ink truncate">{display}</div>
{profile.email && (
<div className="text-cell text-mute truncate">{String(profile.email)}</div>
)}
</div>
)}
{!collapsed && (
<button
type="button"
onClick={() => auth.signoutRedirect().catch(() => auth.removeUser())}
title={t('auth.logout', { defaultValue: 'Logout' })}
aria-label={t('auth.logout', { defaultValue: 'Logout' })}
className="size-7 rounded-md text-mute hover:text-ink hover:bg-surface-2 inline-flex items-center justify-center transition-colors shrink-0"
>
<span aria-hidden></span>
</button>
)}
</div>
)
}
function SignInCta({
auth,
collapsed,
}: {
auth: ReturnType<typeof useAuth>
collapsed: boolean
}) {
const { t } = useTranslation()
return (
<button
type="button"
onClick={() => auth.signinRedirect()}
className={cn(
'border-t border-line py-2.5 text-cell text-ink-2 hover:text-ink hover:bg-surface-2 inline-flex items-center gap-2 transition-colors shrink-0',
collapsed ? 'px-2 justify-center' : 'px-3',
)}
title={t('auth.login', { defaultValue: 'Войти' })}
>
<span aria-hidden></span>
{!collapsed && t('auth.login', { defaultValue: 'Войти' })}
</button>
)
}
/**
* MobileSidebar — Sheet с тем же содержимым что desktop Sidebar. Открывается
* через hamburger button в TopBar (<1024px), закрывается на: backdrop click,
* Escape, или клик на любой nav item (auto-close, привычная mobile UX).
*
* <p>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
const dictsQuery = useDictionaries()
const myDrafts = useMyDrafts(0, 1)
const mySchemaDrafts = useMySchemaDrafts(0, 1)
const reviews = useReviewsBadgeCount(auth.isAuthenticated)
const outboxStats = useOutboxStats()
const webhooks = useWebhookSubscriptions()
const dictsCount = dictsQuery.data?.length ?? 0
const myDraftsCount =
(myDrafts.data?.totalElements ?? 0) + (mySchemaDrafts.data?.totalElements ?? 0)
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.
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: '/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,
},
{
to: '/webhooks',
label: t('nav.webhooks'),
icon: Webhook,
badge: webhooksActive > 0 ? webhooksActive : undefined,
authRequired: true,
},
{ to: '/audit', label: t('nav.audit'), icon: ScrollText, authRequired: 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
return true
}),
}))
.filter((s) => s.items.length > 0)
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent
side="left"
size="md"
className="!w-[260px] !max-w-[260px] !p-0 !gap-0 flex flex-col bg-surface"
>
<SidebarContent sections={visibleSections} auth={auth} onNavigate={onClose} />
</SheetContent>
</Sheet>
)
}
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 (
<Link
to={to}
onClick={onClick}
title={collapsed ? (typeof label === 'string' ? label : undefined) : undefined}
className={cn(
// text-body = 13px workhorse per handoff (см. styles.css @utility).
'group flex items-center rounded-md text-body transition-colors',
collapsed ? 'justify-center h-9 mx-1' : 'gap-2.5 px-3 py-1.5',
active
? 'bg-accent-bg text-accent font-medium'
: 'text-ink-2 hover:text-ink hover:bg-surface-2',
)}
>
<Icon size={collapsed ? 18 : 15} strokeWidth={1.75} className="shrink-0" />
{!collapsed && (
<>
<span className="flex-1 truncate">{label}</span>
{badge !== undefined && (
<span
className={cn(
'inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-sm text-mono',
active
? 'bg-accent text-on-accent'
: 'bg-surface-2 text-ink-2 group-hover:bg-line',
)}
>
{badge}
</span>
)}
</>
)}
</Link>
)
}