Files
hr-portal/frontend/components/layout/sidebar.tsx
Porsche Chen 360533393f feat: HR Portal - Complete Multi-Tenant System with Redis Session Storage
Major Features:
-  Multi-tenant architecture (tenant isolation)
-  Employee CRUD with lifecycle management (onboarding/offboarding)
-  Department tree structure with email domain management
-  Company info management (single-record editing)
-  System functions CRUD (permission management)
-  Email account management (multi-account per employee)
-  Keycloak SSO integration (auth.lab.taipei)
-  Redis session storage (10.1.0.254:6379)
  - Solves Cookie 4KB limitation
  - Cross-system session sharing
  - Sliding expiration (8 hours)
  - Automatic token refresh

Technical Stack:
Backend:
- FastAPI + SQLAlchemy
- PostgreSQL 16 (10.1.0.20:5433)
- Keycloak Admin API integration
- Docker Mailserver integration (SSH)
- Alembic migrations

Frontend:
- Next.js 14 (App Router)
- NextAuth 4 with Keycloak Provider
- Redis session storage (ioredis)
- Tailwind CSS

Infrastructure:
- Redis 7 (10.1.0.254:6379) - Session + Cache
- Keycloak 26.1.0 (auth.lab.taipei)
- Docker Mailserver (10.1.0.254)

Architecture Highlights:
- Session管理由 Keycloak + Redis 統一控制
- 支援多系統 (HR/WebMail/Calendar/Drive/Office) 共享 session
- Token 自動刷新,異質服務整合
- 未來可無縫遷移到雲端

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-23 20:12:43 +08:00

238 lines
7.5 KiB
TypeScript

/**
* 側邊欄導航 (動態功能列表)
*/
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { signOut, useSession } from 'next-auth/react'
import { useState, useEffect } from 'react'
import apiClient from '@/lib/api-client'
import { systemFunctionService, SystemFunctionNode } from '@/services/systemFunction.service'
// 預設圖示
const DefaultIcon = () => (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
)
// 節點分類圖示
const NodeIcon = () => (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
)
interface MenuItemProps {
item: SystemFunctionNode
level: number
pathname: string
}
function MenuItem({ item, level, pathname }: MenuItemProps) {
const [isExpanded, setIsExpanded] = useState(true)
const hasChildren = item.children && item.children.length > 0
const isNode = item.function_type === 1
const route = systemFunctionService.codeToRoute(item.code)
const isActive = pathname === route || pathname.startsWith(route + '/')
if (isNode) {
// NODE: 顯示為分類標題
return (
<div className="mb-1">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center px-3 py-1.5 text-sm font-semibold text-gray-400 hover:text-white transition-colors"
>
{item.function_icon ? (
<span className="text-lg">{item.function_icon}</span>
) : (
<NodeIcon />
)}
<span className="ml-3 flex-1 text-left">{item.name}</span>
{hasChildren && (
<svg
className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
)}
</button>
{/* 子選單 */}
{hasChildren && isExpanded && (
<div className="ml-4 mt-1 space-y-1 border-l-2 border-gray-700 pl-2">
{item.children.map(child => (
<MenuItem key={child.id} item={child} level={level + 1} pathname={pathname} />
))}
</div>
)}
</div>
)
}
// FUNCTION: 顯示為可點擊連結
return (
<Link
href={route}
className={`flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
level > 0 ? 'ml-2' : ''
} ${
isActive
? 'bg-gray-800 text-white'
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
}`}
>
{item.function_icon ? (
<span className="text-lg">{item.function_icon}</span>
) : (
<DefaultIcon />
)}
<span className="ml-3">{item.name}</span>
</Link>
)
}
export function Sidebar() {
const pathname = usePathname()
const { data: session } = useSession()
const [tenantName, setTenantName] = useState<string>('')
const [menuTree, setMenuTree] = useState<SystemFunctionNode[]>([])
const [loading, setLoading] = useState(true)
// 取得租戶資訊
useEffect(() => {
const fetchTenantInfo = async () => {
try {
const data: any = await apiClient.get('/tenants/current')
setTenantName(data.name || data.code)
} catch (error) {
console.error('Failed to fetch tenant info:', error)
}
}
if (session?.user) {
fetchTenantInfo()
}
}, [session])
// 載入功能列表
useEffect(() => {
const loadMenu = async () => {
try {
// 直接從後端 API 取得租戶資訊
let isSysmana = false
try {
const tenantResponse: any = await apiClient.get('/tenants/current')
isSysmana = tenantResponse.is_sysmana || false
console.log('[Sidebar] Tenant is_sysmana:', isSysmana)
} catch (error) {
console.error('[Sidebar] Failed to fetch tenant info:', error)
// 如果取得失敗,嘗試從 session 取得
isSysmana = (session?.user as any)?.tenant?.is_sysmana || false
}
console.log('[Sidebar] Loading menu with is_sysmana:', isSysmana)
const tree = await systemFunctionService.getMenuTree(isSysmana)
console.log('[Sidebar] Loaded menu tree:', tree.length, 'items')
setMenuTree(tree)
} catch (error) {
console.error('[Sidebar] Failed to load menu:', error)
// 失敗時使用空陣列
setMenuTree([])
} finally {
setLoading(false)
}
}
if (session?.user) {
loadMenu()
}
}, [session])
return (
<div className="flex flex-col h-full bg-gray-900 text-white">
{/* Logo */}
<div className="flex items-center h-16 px-6 bg-gray-800">
<div>
<h1 className="text-xl font-bold">HR Portal</h1>
{tenantName && (
<p className="text-xs text-gray-400 mt-0.5">({tenantName})</p>
)}
</div>
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-8 text-gray-400">
<svg className="animate-spin h-8 w-8 mr-3" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span>...</span>
</div>
) : menuTree.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<p></p>
</div>
) : (
menuTree.map(item => (
<MenuItem key={item.id} item={item} level={0} pathname={pathname} />
))
)}
</nav>
{/* User Info & Logout */}
<div className="p-4 bg-gray-800 border-t border-gray-700">
{session?.user && (
<div className="mb-3 px-2">
<p className="text-sm font-medium">{session.user.name}</p>
<p className="text-xs text-gray-400">{session.user.email}</p>
</div>
)}
<button
onClick={() => signOut({ callbackUrl: '/auth/signin' })}
className="w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
</button>
</div>
</div>
)
}