Files
hr-portal/frontend/app/system-functions/page.tsx
Porsche Chen 360533393f 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>
2026-02-23 20:12:43 +08:00

601 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
)
}