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:
2026-02-23 20:12:43 +08:00
commit 360533393f
386 changed files with 70353 additions and 0 deletions

View File

@@ -0,0 +1,220 @@
/**
* 主控台首頁
*/
'use client'
import { useSession } from 'next-auth/react'
import { useEffect, useState } from 'react'
import apiClient from '@/lib/api-client'
interface DashboardStats {
employeeCount: number
topDeptCount: number
totalDeptCount: number
pendingTaskCount: number
}
export default function DashboardPage() {
const { data: session } = useSession()
const [stats, setStats] = useState<DashboardStats>({
employeeCount: 0,
topDeptCount: 0,
totalDeptCount: 0,
pendingTaskCount: 0,
})
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchStats = async () => {
try {
setLoading(true)
// 員工數量
const employeesData: any = await apiClient.get('/employees/')
// 部門統計 (新架構: 從 tree API 取得)
let topDeptCount = 0
let totalDeptCount = 0
try {
const treeData: any = await apiClient.get('/departments/tree')
if (Array.isArray(treeData)) {
topDeptCount = treeData.length
const countAllNodes = (nodes: any[]): number => {
return nodes.reduce((sum: number, node: any) => {
return sum + 1 + (node.children ? countAllNodes(node.children) : 0)
}, 0)
}
totalDeptCount = countAllNodes(treeData)
}
} catch (error) {
console.warn('Failed to fetch department tree:', error)
}
setStats({
employeeCount: employeesData?.total || 0,
topDeptCount,
totalDeptCount,
pendingTaskCount: 0,
})
} catch (error) {
console.error('Failed to fetch dashboard stats:', error)
} finally {
setLoading(false)
}
}
if (session) {
fetchStats()
}
}, [session])
const statCards = [
{
name: '在職員工',
value: loading ? '...' : stats.employeeCount.toString(),
icon: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
),
color: 'bg-blue-500',
},
{
name: '第一層部門數',
value: loading ? '...' : stats.topDeptCount.toString(),
icon: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
),
color: 'bg-green-500',
},
{
name: '部門總數',
value: loading ? '...' : stats.totalDeptCount.toString(),
icon: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
),
color: 'bg-purple-500',
},
{
name: '待處理事項',
value: loading ? '...' : stats.pendingTaskCount.toString(),
icon: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
color: 'bg-orange-500',
},
]
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">!</h1>
<p className="mt-2 text-gray-600">
{session?.user?.name || '管理員'}, HR Portal
</p>
</div>
{/* 統計卡片 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{statCards.map((stat) => (
<div key={stat.name} className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">{stat.name}</p>
<p className="mt-2 text-3xl font-bold text-gray-900">{stat.value}</p>
</div>
<div className={`${stat.color} p-3 rounded-lg text-white`}>{stat.icon}</div>
</div>
</div>
))}
</div>
{/* 快速操作 */}
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
<h2 className="text-xl font-bold text-gray-900 mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<button className="flex items-center justify-center px-4 py-3 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 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="M12 4v16m8-8H4"
/>
</svg>
</button>
<button className="flex items-center justify-center px-4 py-3 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 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="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>
</button>
<button className="flex items-center justify-center px-4 py-3 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
</div>
</div>
{/* 最近活動 */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4"></h2>
<div className="space-y-4">
<div className="flex items-start">
<div className="flex-shrink-0 w-2 h-2 mt-2 rounded-full bg-blue-500"></div>
<div className="ml-3">
<p className="text-sm font-medium text-gray-900"></p>
<p className="text-sm text-gray-500">使 HR Portal</p>
<p className="text-xs text-gray-400 mt-1"></p>
</div>
</div>
</div>
</div>
</div>
)
}