7e435e07b7
Phase 1a из плана: только swap библиотеки, realm/URL остаются на auth.nstart.space/realms/nstart. Phase 2 (intgr с altum auth-service) — отдельный эпик. Зачем. oidc-client-ts с automaticSilentRenew триггерил бесконечный POST /token loop когда refresh_token истекал (см. MR !269 revert, ~30+ 400'к в console, страница залипала на 403). Altum давно ушёл с oidc-client-ts на keycloak-js по той же причине. Новая модель. - src/lib/keycloak.ts — singleton + initKeycloak(check-sso, PKCE) + ensureFreshToken(N) wrapper над kc.updateToken(). Зеркало altum/src/lib/keycloak.ts. - src/stores/authStore.ts — Zustand store: {user, isAuthenticated, isLoading, error}. checkAuth() ждёт initKeycloak, wireKeycloakEvents() → onAuthSuccess/Refresh/Logout/TokenExpired re-снэпшотят store. - src/auth/useAuth.ts — compat shim для react-oidc-context API (auth.user.profile, isAuthenticated, signinSilent, signinRedirect, signoutRedirect, removeUser, error). Все 11 callsites продолжают работать без правок (только сменили путь import'а). - src/api/client.ts — request interceptor дёргает ensureFreshToken(30) ПЕРЕД каждым запросом + attaches Bearer ${getToken()}. 401 interceptor делает ensureFreshToken(0) + retry один раз. Никаких таймеров, никаких setAccessToken/setUnauthorizedHandler holder'ов — keycloak-js сам source of truth. - src/main.tsx — убран <AuthProvider>, <TokenSync />. Просто useAuthStore.getState().checkAuth() на старте, потом обычный render. - public/silent-check-sso.html — endpoint для silentCheckSsoRedirectUri. Удалены. - src/auth/TokenSync.tsx — не нужен, ensureFreshToken на запросах. - src/auth/oidcConfig.ts — не нужен, конфиг внутри lib/keycloak.ts. - npm deps: react-oidc-context, oidc-client-ts. Добавлены deps: keycloak-js@26.2.4, zustand@5.0.13. Tests: 171/171 passed, TSC чистый. Migration callsites — все unchanged по семантике благодаря compat shim.
673 lines
26 KiB
TypeScript
673 lines
26 KiB
TypeScript
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<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)}
|
||
// h-11 + min-w-11 — DESIGN.md §13 touch target enforcement.
|
||
className="h-11 min-w-11 px-2.5 rounded-md border border-line bg-surface text-cap text-ink-2 hover:bg-surface-2 hover:text-ink transition-colors inline-flex items-center justify-center"
|
||
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' })}
|
||
// size-11 = 44×44 WCAG AAA per DESIGN.md §13.
|
||
className="size-11 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
|
||
// 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 (
|
||
<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"
|
||
>
|
||
{/* 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 открыт. */}
|
||
<SheetTitle className="sr-only">
|
||
{t('nav.menu', { defaultValue: 'Меню навигации' })}
|
||
</SheetTitle>
|
||
<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}
|
||
// Tooltip в collapsed-mode включает label + текущий badge (если есть),
|
||
// чтобы юзер видел число при hover на узкой полоске.
|
||
title={
|
||
collapsed
|
||
? typeof label === 'string'
|
||
? badge !== undefined
|
||
? `${label} (${badge})`
|
||
: 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 + corner-badge overlay в collapsed-mode. Wrapper relative нужен
|
||
для absolute badge'а; в expanded-mode иконка без обёртки + badge
|
||
справа от label'а как было. */}
|
||
{collapsed ? (
|
||
<span className="relative inline-flex items-center justify-center">
|
||
<Icon size={18} strokeWidth={1.75} className="shrink-0" />
|
||
{badge !== undefined && (
|
||
<span
|
||
aria-hidden="true"
|
||
className={cn(
|
||
'absolute -top-1.5 -right-1.5 inline-flex items-center justify-center',
|
||
'min-w-[1rem] h-4 px-1 rounded-full text-cap font-mono leading-none',
|
||
'bg-accent text-on-accent ring-1 ring-surface',
|
||
)}
|
||
>
|
||
{typeof badge === 'number' && badge > 99 ? '99+' : badge}
|
||
</span>
|
||
)}
|
||
</span>
|
||
) : (
|
||
<>
|
||
<Icon size={15} strokeWidth={1.75} className="shrink-0" />
|
||
<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>
|
||
)
|
||
}
|