Files
mdm-ordinis/ordinis-admin-ui/src/components/layout/Sidebar.tsx
T
Zimin A.N. 5a2220d779 feat(editor): ExportModal + TimeTravelModal + InfoPanel merge + fixes
UI redesign matching prototype (Ordinis (3) dump):
- ExportModal: format (CSV/JSON/Excel/SQL) + scope (all/filtered/selected) +
  column toggles + encoding/delimiter + preview filename per redesign line
  1059-1092. CSV uses backend mutation, JSON client-side from filtered rows,
  Excel/SQL disabled (backend pending).
- TimeTravelModal: full-page compare СЕЙЧАС vs ТОЧКА ВО ВРЕМЕНИ + quick
  presets (сейчас/неделя/месяц/год/при создании) + step toggle версия/день
  + slider + tabs (Что изменилось / Записи на момент / Структура и поля) +
  "Открыть как readonly" CTA. Replaces inline TimeTravelPicker.
- InfoPanel ← используют merge: rich rows с field path + active records
  count + onClose policy chip (BLOCK/WARN/CASCADE). Inline
  DictionaryDependentsPanel above records table removed (one source of truth).
- Sidebar logo: match redesign/ui-kit.html .brand SVG (outer ring + inner
  faded ring + orbit dot offset right cx=22.5). ORDINIS Tektur tracking 0.22em
  + MDM smaller mute 0.32em.
- Bg neutralized: #fbf8ee Earth cream → #faf9f5 warm neutral default
  (per user preference, Earth cream still described in comment как opt-in).
- WorkflowBanner: Case 1 Live (approvalRequired=false) returns null —
  permanent "Опубликовано" banner = noise, status already visible в
  InfoPanel scope/version/updatedAt. Banner показывается только когда
  есть actionable state.

Critical bug fixes:
- fix(CascadeConfirmDialog): infinite render loop "Maximum update depth
  exceeded". useEffect had cascadeMut (TanStack Query mutation wrapper) в
  deps — wrapper identity changes every render → effect fires → reset() →
  store update → render → loop. Extract stable `reset` reference перед
  useEffect. Симптомы у user: создание/редактирование записи не работали.
- fix(semantic-release): add missing peer dep
  conventional-changelog-conventionalcommits (9.3.1). semantic-release 22+
  больше не bundles preset. Без неё release job падал с
  "Cannot find module 'conventional-changelog-conventionalcommits'".
2026-05-11 19:01:57 +03:00

420 lines
14 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,
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<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
}) {
const { t } = useTranslation()
return (
<>
{/* Logo block per redesign prototype: circle (accent) + ORDINIS + MDM mute */}
<div
className={cn(
'h-14 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"
>
{/* 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. */}
<svg
width="24"
height="24"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
className="shrink-0 text-accent"
>
<circle cx="16" cy="16" r="13" stroke="currentColor" strokeWidth="1.6" />
<circle cx="16" cy="16" r="6" stroke="currentColor" strokeWidth="1" opacity="0.45" />
<circle cx="22.5" cy="16" r="2.2" fill="currentColor" />
</svg>
{!collapsed && (
<span className="font-display text-[15px] font-semibold 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>
{/* User block — sticky bottom */}
{auth.isAuthenticated && auth.user?.profile && (
<div
className={cn(
'border-t border-line py-3 flex items-center gap-2 shrink-0',
collapsed ? 'px-2 justify-center' : 'px-3',
)}
title={String(
auth.user.profile.preferred_username ||
auth.user.profile.name ||
auth.user.profile.email ||
'user',
)}
>
<div className="size-8 rounded-full bg-accent text-on-accent inline-flex items-center justify-center text-cell font-semibold shrink-0">
{String(
auth.user.profile.preferred_username ||
auth.user.profile.name ||
'U',
)
.charAt(0)
.toUpperCase()}
</div>
{!collapsed && (
<div className="min-w-0 flex-1">
<div className="text-cell font-medium text-ink truncate">
{String(
auth.user.profile.preferred_username ||
auth.user.profile.name ||
auth.user.profile.email ||
'user',
)}
</div>
{auth.user.profile.email && (
<div className="text-cell text-mute truncate">{String(auth.user.profile.email)}</div>
)}
</div>
)}
</div>
)}
{/* Collapse toggle — desktop only (mobile uses Sheet close ×) */}
{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>
)}
</>
)
}
/**
* 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 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 (
<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>
)
}