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:
220
frontend/app/dashboard/page.tsx
Normal file
220
frontend/app/dashboard/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user