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>
This commit is contained in:
97
frontend/components/layout/breadcrumb.tsx
Normal file
97
frontend/components/layout/breadcrumb.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 麵包屑導航組件
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
interface BreadcrumbItem {
|
||||
name: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
export function Breadcrumb() {
|
||||
const pathname = usePathname()
|
||||
|
||||
// 定義路徑對應的顯示名稱
|
||||
const pathMap: Record<string, string> = {
|
||||
dashboard: '系統首頁',
|
||||
employees: '員工管理',
|
||||
new: '新增員工',
|
||||
edit: '編輯員工',
|
||||
departments: '部門管理',
|
||||
organization: '組織架構',
|
||||
'system-functions': '系統功能管理',
|
||||
'company-info': '公司資料維護',
|
||||
}
|
||||
|
||||
// 解析路徑生成麵包屑
|
||||
const generateBreadcrumbs = (): BreadcrumbItem[] => {
|
||||
const paths = pathname.split('/').filter((path) => path)
|
||||
const breadcrumbs: BreadcrumbItem[] = []
|
||||
|
||||
paths.forEach((path, index) => {
|
||||
// 跳過數字 ID (例如 /employees/123)
|
||||
if (/^\d+$/.test(path)) {
|
||||
breadcrumbs.push({
|
||||
name: `#${path}`,
|
||||
href: paths.slice(0, index + 1).join('/'),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const name = pathMap[path] || path
|
||||
const href = '/' + paths.slice(0, index + 1).join('/')
|
||||
|
||||
breadcrumbs.push({ name, href })
|
||||
})
|
||||
|
||||
return breadcrumbs
|
||||
}
|
||||
|
||||
const breadcrumbs = generateBreadcrumbs()
|
||||
|
||||
// 如果只有一層(例如只在 /dashboard),不顯示麵包屑
|
||||
if (breadcrumbs.length <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="flex mb-6" aria-label="Breadcrumb">
|
||||
<ol className="inline-flex items-center space-x-1 md:space-x-3">
|
||||
{breadcrumbs.map((crumb, index) => {
|
||||
const isLast = index === breadcrumbs.length - 1
|
||||
|
||||
return (
|
||||
<li key={crumb.href || crumb.name} className="inline-flex items-center">
|
||||
{index > 0 && (
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-400 mx-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{isLast ? (
|
||||
<span className="text-sm font-medium text-gray-500">{crumb.name}</span>
|
||||
) : (
|
||||
<Link
|
||||
href={crumb.href!}
|
||||
className="text-sm font-medium text-gray-700 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
{crumb.name}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
237
frontend/components/layout/sidebar.tsx
Normal file
237
frontend/components/layout/sidebar.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 側邊欄導航 (動態功能列表)
|
||||
*/
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user