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,600 @@
'use client'
import { useState, useEffect } from 'react'
import { useSession } from 'next-auth/react'
import apiClient from '@/lib/api-client'
import FunctionFormModal from './FunctionFormModal'
import AlertDialog from '@/components/ui/AlertDialog'
import ConfirmDialog from '@/components/ui/ConfirmDialog'
interface SystemFunction {
id: number
code: string
name: string
function_type: number // 1=NODE, 2=FUNCTION
upper_function_id: number
order: number
function_icon: string
module_code: string | null
module_functions: string[]
description: string
is_mana: boolean
is_active: boolean
}
interface ParentFunction {
id: number
code: string
name: string
}
export default function SystemFunctionsPage() {
const { data: session } = useSession()
const [allFunctions, setAllFunctions] = useState<SystemFunction[]>([]) // 所有資料
const [functions, setFunctions] = useState<SystemFunction[]>([]) // 當前頁面顯示的資料
const [parentFunctions, setParentFunctions] = useState<ParentFunction[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingFunction, setEditingFunction] = useState<SystemFunction | null>(null)
// 對話框狀態
const [alertDialog, setAlertDialog] = useState<{
isOpen: boolean
title: string
message: string
type: 'info' | 'warning' | 'error' | 'success'
}>({
isOpen: false,
title: '',
message: '',
type: 'info',
})
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean
title: string
message: string
type: 'info' | 'warning' | 'error' | 'success'
onConfirm: () => void
}>({
isOpen: false,
title: '',
message: '',
type: 'warning',
onConfirm: () => {},
})
// 篩選條件
const [filterType, setFilterType] = useState<string>('')
const [filterActive, setFilterActive] = useState<string>('')
const [filterParent, setFilterParent] = useState<string>('')
// 分頁與排序
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(5)
const [sortField, setSortField] = useState<'order' | 'id'>('order')
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
// 載入功能列表
const loadFunctions = async () => {
try {
setLoading(true)
console.log('[SystemFunctions] Loading functions...')
// 建立查詢參數 - 不使用 URLSearchParams直接建立 query string
let queryParts = ['page_size=100'] // 後端限制最大 100
if (filterType) {
queryParts.push(`function_type=${filterType}`)
}
// is_active 特殊處理:空字串表示不篩選,所以不加這個參數
if (filterActive) {
queryParts.push(`is_active=${filterActive}`)
} else {
// 不傳 is_active 參數,讓後端使用預設值或不篩選
// 為了顯示所有資料(包含停用),我們明確傳 is_active=None 或不傳
}
if (filterParent) {
queryParts.push(`upper_function_id=${filterParent}`)
}
const queryString = queryParts.join('&')
console.log('[SystemFunctions] Query:', queryString)
const response: any = await apiClient.get(`/system-functions?${queryString}`)
console.log('[SystemFunctions] Response:', response)
console.log('[SystemFunctions] Items count:', response.items?.length || 0)
setAllFunctions(response.items || [])
setCurrentPage(1) // 重置到第一頁
} catch (error) {
console.error('[SystemFunctions] Failed to load functions:', error)
setAlertDialog({
isOpen: true,
title: '載入失敗',
message: `載入系統功能失敗: ${(error as any)?.message || '未知錯誤'}`,
type: 'error',
})
} finally {
setLoading(false)
}
}
// 載入上層功能選項 (用於篩選)
const loadParentFunctions = async () => {
try {
const response: any = await apiClient.get('/system-functions?function_type=1&page_size=100')
const parents = response.items || []
setParentFunctions([{ id: 0, code: 'root', name: '根層 (無上層)' }, ...parents])
} catch (error) {
console.error('Failed to load parent functions:', error)
}
}
useEffect(() => {
if (session) {
loadParentFunctions()
loadFunctions()
}
}, [session])
// 當篩選條件改變時重新載入
useEffect(() => {
if (session) {
loadFunctions()
}
}, [filterType, filterActive, filterParent])
// 排序和分頁處理
useEffect(() => {
// 排序
const sorted = [...allFunctions].sort((a, b) => {
const aValue = a[sortField]
const bValue = b[sortField]
if (sortDirection === 'asc') {
return aValue > bValue ? 1 : -1
} else {
return aValue < bValue ? 1 : -1
}
})
// 分頁
const startIndex = (currentPage - 1) * pageSize
const endIndex = startIndex + pageSize
const paginated = sorted.slice(startIndex, endIndex)
setFunctions(paginated)
}, [allFunctions, currentPage, pageSize, sortField, sortDirection])
// 排序處理
const handleSort = (field: 'order' | 'id') => {
if (sortField === field) {
// 切換排序方向
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
} else {
// 新欄位,預設升序
setSortField(field)
setSortDirection('asc')
}
}
// 計算總頁數
const totalPages = Math.ceil(allFunctions.length / pageSize)
// 切換頁碼
const handlePageChange = (page: number) => {
if (page >= 1 && page <= totalPages) {
setCurrentPage(page)
}
}
// 產生頁碼按鈕
const renderPageNumbers = () => {
const pages = []
const maxVisiblePages = 5
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2))
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1)
if (endPage - startPage < maxVisiblePages - 1) {
startPage = Math.max(1, endPage - maxVisiblePages + 1)
}
for (let i = startPage; i <= endPage; i++) {
pages.push(
<button
key={i}
onClick={() => handlePageChange(i)}
className={`px-3 py-1 rounded ${
i === currentPage
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-100'
} border border-gray-300`}
>
{i}
</button>
)
}
return pages
}
// 新增功能
const handleCreate = () => {
setEditingFunction(null)
setShowModal(true)
}
// 編輯功能
const handleEdit = (func: SystemFunction) => {
setEditingFunction(func)
setShowModal(true)
}
// 刪除功能 (軟刪除)
const handleDelete = (id: number) => {
setConfirmDialog({
isOpen: true,
title: '確認刪除',
message: '確定要刪除此功能嗎?刪除後將無法復原。',
type: 'warning',
onConfirm: async () => {
try {
await apiClient.delete(`/system-functions/${id}`)
loadFunctions()
setAlertDialog({
isOpen: true,
title: '刪除成功',
message: '功能已成功刪除',
type: 'success',
})
} catch (error) {
console.error('Failed to delete function:', error)
setAlertDialog({
isOpen: true,
title: '刪除失敗',
message: '刪除功能時發生錯誤,請稍後再試',
type: 'error',
})
} finally {
setConfirmDialog({ ...confirmDialog, isOpen: false })
}
},
})
}
// 切換啟用狀態
const handleToggleActive = async (func: SystemFunction) => {
try {
await apiClient.patch(`/system-functions/${func.id}`, {
is_active: !func.is_active,
edit_by: 1 // TODO: 從 session 取得當前用戶 ID
})
loadFunctions()
} catch (error) {
console.error('Failed to toggle active status:', error)
setAlertDialog({
isOpen: true,
title: '操作失敗',
message: '切換狀態失敗,請稍後再試',
type: 'error',
})
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-gray-500">...</div>
</div>
)
}
return (
<div>
{/* Header */}
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">🎯 </h1>
<p className="mt-1 text-sm text-gray-500">
</p>
</div>
<button
onClick={handleCreate}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
+
</button>
</div>
{/* 篩選條件 */}
<div className="mb-4 flex gap-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1"></label>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 bg-white"
>
<option value="" className="text-gray-900"></option>
<option value="1" className="text-gray-900">NODE ()</option>
<option value="2" className="text-gray-900">FUNCTION ()</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1"></label>
<select
value={filterActive}
onChange={(e) => setFilterActive(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 bg-white"
>
<option value="" className="text-gray-900"></option>
<option value="true" className="text-gray-900"></option>
<option value="false" className="text-gray-900"></option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1"></label>
<select
value={filterParent}
onChange={(e) => setFilterParent(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 bg-white"
>
<option value="" className="text-gray-900"></option>
{parentFunctions.map(parent => (
<option key={parent.id} value={parent.id} className="text-gray-900">
{parent.name}
</option>
))}
</select>
</div>
{(filterType || filterActive || filterParent) && (
<div className="flex items-end">
<button
onClick={() => {
setFilterType('')
setFilterActive('')
setFilterParent('')
}}
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-900"
>
</button>
</div>
)}
</div>
{/* 每頁筆數選擇 */}
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<label className="text-sm text-gray-700"></label>
<select
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value))
setCurrentPage(1)
}}
className="px-3 py-1 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 bg-white"
>
<option value={5} className="text-gray-900">5</option>
<option value={10} className="text-gray-900">10</option>
<option value={15} className="text-gray-900">15</option>
<option value={20} className="text-gray-900">20</option>
<option value={25} className="text-gray-900">25</option>
<option value={50} className="text-gray-900">50</option>
</select>
<span className="text-sm text-gray-700"></span>
</div>
<div className="text-sm text-gray-700">
{allFunctions.length}
</div>
</div>
{/* Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-blue-900">
<tr>
<th className="px-6 py-3 text-left text-sm font-medium text-white uppercase tracking-wider">
ID
</th>
<th className="px-6 py-3 text-left text-sm font-medium text-white uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-sm font-medium text-white uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-sm font-medium text-white uppercase tracking-wider">
</th>
<th
className="px-6 py-3 text-left text-sm font-medium text-white uppercase tracking-wider cursor-pointer hover:bg-blue-800"
onClick={() => handleSort('order')}
>
<div className="flex items-center gap-1">
{sortField === 'order' && (
<span className="text-yellow-300">
{sortDirection === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
</th>
<th className="px-6 py-3 text-center text-sm font-medium text-white uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-center text-sm font-medium text-white uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{functions.map((func) => (
<tr key={func.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{func.id}
</td>
<td className="px-6 py-4 whitespace-nowrap text-xl">
{func.function_icon}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
{func.code}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{func.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{func.order}
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<button
onClick={() => handleToggleActive(func)}
className={`px-3 py-1 rounded text-xs font-medium transition-colors cursor-pointer ${
func.is_active
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
}`}
>
{func.is_active ? '是' : '否'}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center text-sm font-medium space-x-3">
<button
onClick={() => handleEdit(func)}
className="px-3 py-1 border border-blue-600 text-blue-600 rounded hover:bg-blue-50 transition-colors"
>
</button>
<button
onClick={() => handleDelete(func.id)}
className="px-3 py-1 border border-red-600 text-red-600 rounded hover:bg-red-50 transition-colors"
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{allFunctions.length === 0 && !loading && (
<div className="text-center py-12 text-gray-500">
</div>
)}
{/* 分頁控制 */}
{allFunctions.length > 0 && (
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-gray-700">
{(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, allFunctions.length)}
{allFunctions.length}
</div>
<div className="flex items-center gap-2">
{/* 第一頁 */}
<button
onClick={() => handlePageChange(1)}
disabled={currentPage === 1}
className="px-3 py-1 rounded bg-white text-gray-700 border border-gray-300 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
««
</button>
{/* 上一頁 */}
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="px-3 py-1 rounded bg-white text-gray-700 border border-gray-300 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
«
</button>
{/* 頁碼 */}
{renderPageNumbers()}
{/* 下一頁 */}
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-3 py-1 rounded bg-white text-gray-700 border border-gray-300 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
»
</button>
{/* 最後一頁 */}
<button
onClick={() => handlePageChange(totalPages)}
disabled={currentPage === totalPages}
className="px-3 py-1 rounded bg-white text-gray-700 border border-gray-300 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
»»
</button>
{/* 跳頁 */}
<div className="flex items-center gap-2 ml-4">
<span className="text-sm text-gray-700"></span>
<input
type="number"
min={1}
max={totalPages}
value={currentPage}
onChange={(e) => {
const page = Number(e.target.value)
if (page >= 1 && page <= totalPages) {
handlePageChange(page)
}
}}
className="w-16 px-2 py-1 border border-gray-300 rounded text-center text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700"></span>
</div>
</div>
</div>
)}
{/* Modal */}
{showModal && (
<FunctionFormModal
function={editingFunction}
onClose={() => setShowModal(false)}
onSave={(isEdit: boolean) => {
setShowModal(false)
loadFunctions()
setAlertDialog({
isOpen: true,
title: '儲存成功',
message: `功能已成功${isEdit ? '更新' : '新增'}`,
type: 'success',
})
}}
/>
)}
{/* Alert Dialog */}
<AlertDialog
isOpen={alertDialog.isOpen}
title={alertDialog.title}
message={alertDialog.message}
type={alertDialog.type}
onConfirm={() => setAlertDialog({ ...alertDialog, isOpen: false })}
/>
{/* Confirm Dialog */}
<ConfirmDialog
isOpen={confirmDialog.isOpen}
title={confirmDialog.title}
message={confirmDialog.message}
type={confirmDialog.type}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog({ ...confirmDialog, isOpen: false })}
/>
</div>
)
}