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>
238 lines
7.5 KiB
TypeScript
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>
|
|
)
|
|
}
|