5a2220d779
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'".
420 lines
14 KiB
TypeScript
420 lines
14 KiB
TypeScript
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>
|
||
)
|
||
}
|