- Refactor sidebar to Nothing.tech visual compliance - Add defensive route stubs for /authorizations, /knowledge-base, /settings - Dynamic badge for pending approvals count - Ultra-minimal borders (0.5px), no shadows Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
222 lines
6.8 KiB
TypeScript
222 lines
6.8 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* Sidebar - Phase 7.0 極簡五柱導航
|
|
* =================================
|
|
* Nothing.tech 視覺憲法:
|
|
* - 純白背景 (bg-white)
|
|
* - 極細右邊框 (border-r-[0.5px] border-neutral-200)
|
|
* - 無陰影
|
|
* - 單色圖示
|
|
*
|
|
* 5 大核心樞紐:
|
|
* 1. 全局戰情室 (/)
|
|
* 2. 授權中心 (/authorizations) - 含動態徽章
|
|
* 3. 行動日誌 (/action-logs)
|
|
* 4. 知識殿堂 (/knowledge-base)
|
|
* 5. 系統設定 (/settings)
|
|
*/
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import { useTranslations } from 'next-intl'
|
|
import { usePathname } from 'next/navigation'
|
|
import Link from 'next/link'
|
|
import { cn } from '@/lib/utils'
|
|
import {
|
|
LayoutDashboard,
|
|
ShieldCheck,
|
|
Zap,
|
|
BookOpen,
|
|
Settings,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
} from 'lucide-react'
|
|
import { apiClient } from '@/lib/api-client'
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
interface SidebarProps {
|
|
locale: string
|
|
collapsed?: boolean
|
|
onToggle?: () => void
|
|
className?: string
|
|
}
|
|
|
|
type NavItemConfig = {
|
|
id: string
|
|
href: string
|
|
labelKey: string
|
|
Icon: typeof LayoutDashboard
|
|
badge?: boolean // 是否顯示動態徽章
|
|
}
|
|
|
|
// =============================================================================
|
|
// Phase 7.0: 5 大核心樞紐
|
|
// =============================================================================
|
|
|
|
const NAV_ITEMS: NavItemConfig[] = [
|
|
{ id: 'warroom', href: '/', labelKey: 'dashboard', Icon: LayoutDashboard },
|
|
{ id: 'authorizations', href: '/authorizations', labelKey: 'approvals', Icon: ShieldCheck, badge: true },
|
|
{ id: 'action-logs', href: '/action-logs', labelKey: 'actions', Icon: Zap },
|
|
{ id: 'knowledge-base', href: '/knowledge-base', labelKey: 'knowledge', Icon: BookOpen },
|
|
{ id: 'settings', href: '/settings', labelKey: 'settings', Icon: Settings },
|
|
]
|
|
|
|
// =============================================================================
|
|
// Component
|
|
// =============================================================================
|
|
|
|
export function Sidebar({
|
|
locale,
|
|
collapsed = false,
|
|
onToggle,
|
|
className,
|
|
}: SidebarProps) {
|
|
const t = useTranslations('nav')
|
|
const tBrand = useTranslations('brand')
|
|
const pathname = usePathname()
|
|
const [pendingCount, setPendingCount] = useState(0)
|
|
|
|
// 載入待簽核數量 (動態徽章)
|
|
useEffect(() => {
|
|
const fetchPendingCount = async () => {
|
|
try {
|
|
const data = await apiClient.getPendingApprovals()
|
|
setPendingCount(data.count || 0)
|
|
} catch {
|
|
setPendingCount(0)
|
|
}
|
|
}
|
|
|
|
fetchPendingCount()
|
|
// 每 30 秒刷新
|
|
const interval = setInterval(fetchPendingCount, 30000)
|
|
return () => clearInterval(interval)
|
|
}, [])
|
|
|
|
const isActive = (href: string) => {
|
|
const fullHref = `/${locale}${href === '/' ? '' : href}`
|
|
if (href === '/') {
|
|
return pathname === `/${locale}` || pathname === `/${locale}/`
|
|
}
|
|
return pathname === fullHref || pathname.startsWith(fullHref + '/')
|
|
}
|
|
|
|
return (
|
|
<aside
|
|
className={cn(
|
|
'fixed left-0 top-0 h-screen z-40',
|
|
'flex flex-col',
|
|
// Phase 7.0: 純白極簡
|
|
'bg-white',
|
|
'border-r-[0.5px] border-neutral-200',
|
|
// 寬度動畫
|
|
'transition-all duration-300 ease-out',
|
|
collapsed ? 'w-16' : 'w-56',
|
|
className
|
|
)}
|
|
>
|
|
{/* Logo 區 - 極簡化 */}
|
|
<div className={cn(
|
|
'flex items-center border-b-[0.5px] border-neutral-200',
|
|
'transition-all duration-300',
|
|
collapsed ? 'h-16 justify-center px-2' : 'h-16 px-4'
|
|
)}>
|
|
{collapsed ? (
|
|
<span className="font-mono text-lg font-bold text-neutral-900 tracking-tighter">
|
|
A
|
|
</span>
|
|
) : (
|
|
<span className="font-mono text-sm font-bold text-neutral-900 tracking-widest uppercase">
|
|
{tBrand('name')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* 導航列表 - 5 大核心樞紐 */}
|
|
<nav className="flex-1 py-4 overflow-y-auto">
|
|
<ul className="space-y-0.5 px-2">
|
|
{NAV_ITEMS.map((item) => {
|
|
const active = isActive(item.href)
|
|
return (
|
|
<li key={item.id}>
|
|
<Link
|
|
href={`/${locale}${item.href === '/' ? '' : item.href}`}
|
|
className={cn(
|
|
'flex items-center gap-3 px-3 py-2.5 rounded-sm',
|
|
'transition-all duration-150',
|
|
'relative',
|
|
// Phase 7.0 視覺規範
|
|
active
|
|
? 'font-bold text-black bg-neutral-100'
|
|
: 'text-neutral-500 hover:bg-neutral-50 hover:text-black',
|
|
collapsed && 'justify-center px-2'
|
|
)}
|
|
>
|
|
<item.Icon className={cn(
|
|
'w-[18px] h-[18px] flex-shrink-0',
|
|
active ? 'text-black' : 'text-neutral-400'
|
|
)} />
|
|
|
|
{!collapsed && (
|
|
<span className="text-[13px] tracking-wide truncate">
|
|
{t(item.labelKey)}
|
|
</span>
|
|
)}
|
|
|
|
{/* 動態徽章 - 授權中心 */}
|
|
{item.badge && pendingCount > 0 && (
|
|
<span className={cn(
|
|
'flex items-center justify-center',
|
|
'min-w-[18px] h-[18px] px-1',
|
|
'text-[10px] font-mono font-bold',
|
|
'bg-neutral-900 text-white rounded-sm',
|
|
collapsed ? 'absolute top-1 right-1' : 'ml-auto'
|
|
)}>
|
|
{pendingCount > 99 ? '99+' : pendingCount}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
</nav>
|
|
|
|
{/* 底部區域 - 極簡版本號 */}
|
|
<div className={cn(
|
|
'border-t-[0.5px] border-neutral-200 py-3',
|
|
collapsed ? 'px-2 text-center' : 'px-4'
|
|
)}>
|
|
{!collapsed && (
|
|
<span className="text-[10px] font-mono text-neutral-300 uppercase tracking-widest">
|
|
v1.0.0
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* 折疊按鈕 */}
|
|
<button
|
|
onClick={onToggle}
|
|
className={cn(
|
|
'absolute -right-3 top-20',
|
|
'w-6 h-6 rounded-full',
|
|
'bg-white border-[0.5px] border-neutral-200',
|
|
'flex items-center justify-center',
|
|
'hover:bg-neutral-50',
|
|
'transition-all duration-150'
|
|
)}
|
|
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
>
|
|
{collapsed ? (
|
|
<ChevronRight className="w-3 h-3 text-neutral-400" />
|
|
) : (
|
|
<ChevronLeft className="w-3 h-3 text-neutral-400" />
|
|
)}
|
|
</button>
|
|
</aside>
|
|
)
|
|
}
|