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,444 @@
'use client'
import { useState, useEffect } from 'react'
import apiClient from '@/lib/api-client'
import AlertDialog from '@/components/ui/AlertDialog'
import ConfirmDialog from '@/components/ui/ConfirmDialog'
import DepartmentFormModal from './DepartmentFormModal'
interface Department {
id: number
tenant_id: number
parent_id: number | null
code: string
name: string
name_en: string | null
depth: number
email_domain: string | null
effective_email_domain: string | null
email_address: string | null
email_quota_mb: number
description: string | null
is_active: boolean
member_count: number
}
interface ParentDepartment {
id: number
name: string
depth: number
}
export default function DepartmentsPage() {
const [departments, setDepartments] = useState<Department[]>([])
const [parentDepartments, setParentDepartments] = useState<ParentDepartment[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingDepartment, setEditingDepartment] = useState<Department | null>(null)
// 篩選條件
const [filterDepth, setFilterDepth] = useState<string>('all')
const [filterActive, setFilterActive] = useState<string>('all')
const [filterParent, setFilterParent] = useState<string>('all')
// 分頁
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(5)
// 對話框
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
onConfirm: () => void
}>({
isOpen: false,
title: '',
message: '',
onConfirm: () => {},
})
useEffect(() => {
loadDepartments()
loadParentDepartments()
}, [])
const loadDepartments = async () => {
try {
setLoading(true)
const response: any = await apiClient.get('/departments?include_inactive=true')
setDepartments(response || [])
} catch (error: any) {
console.error('Failed to load departments:', error)
setAlertDialog({
isOpen: true,
title: '載入失敗',
message: error.response?.data?.detail || '無法載入部門資料',
type: 'error',
})
} finally {
setLoading(false)
}
}
const loadParentDepartments = async () => {
try {
// 載入所有部門作為上層部門選項
const response: any = await apiClient.get('/departments?include_inactive=false')
setParentDepartments(response || [])
} catch (error) {
console.error('Failed to load parent departments:', error)
}
}
const handleAdd = () => {
setEditingDepartment(null)
setShowModal(true)
}
const handleEdit = (department: Department) => {
setEditingDepartment(department)
setShowModal(true)
}
const handleDelete = (department: Department) => {
setConfirmDialog({
isOpen: true,
title: '確認刪除',
message: `確定要刪除部門「${department.name}」嗎?此操作無法復原。`,
onConfirm: async () => {
try {
await apiClient.delete(`/departments/${department.id}`)
loadDepartments()
setAlertDialog({
isOpen: true,
title: '刪除成功',
message: '部門已成功刪除',
type: 'success',
})
} catch (error: any) {
setAlertDialog({
isOpen: true,
title: '刪除失敗',
message: error.response?.data?.detail || '刪除失敗,請稍後再試',
type: 'error',
})
}
setConfirmDialog({ ...confirmDialog, isOpen: false })
},
})
}
const handleToggleActive = async (department: Department) => {
try {
await apiClient.patch(`/departments/${department.id}`, {
is_active: !department.is_active,
})
loadDepartments()
setAlertDialog({
isOpen: true,
title: '切換成功',
message: `部門已${!department.is_active ? '啟用' : '停用'}`,
type: 'success',
})
} catch (error: any) {
setAlertDialog({
isOpen: true,
title: '切換失敗',
message: error.response?.data?.detail || '切換狀態失敗',
type: 'error',
})
}
}
// 篩選邏輯
const filteredDepartments = departments.filter(dept => {
if (filterDepth !== 'all' && dept.depth !== parseInt(filterDepth)) return false
if (filterActive === 'active' && !dept.is_active) return false
if (filterActive === 'inactive' && dept.is_active) return false
if (filterParent === 'top' && dept.parent_id !== null) return false
if (filterParent !== 'all' && filterParent !== 'top' && dept.parent_id !== parseInt(filterParent)) return false
return true
})
// 分頁邏輯
const totalPages = Math.ceil(filteredDepartments.length / pageSize)
const startIndex = (currentPage - 1) * pageSize
const paginatedDepartments = filteredDepartments.slice(startIndex, startIndex + pageSize)
const getParentName = (parentId: number | null) => {
if (!parentId) return '-'
const parent = departments.find(d => d.id === parentId)
return parent ? parent.name : '-'
}
const getDepthLabel = (depth: number) => {
return `${depth + 1}`
}
return (
<div>
{/* Header */}
<div className="mb-6 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-sm text-gray-600 mt-1"></p>
</div>
<button
onClick={handleAdd}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
>
+
</button>
</div>
{/* Filters */}
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200 mb-4">
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1"></label>
<select
value={filterDepth}
onChange={(e) => { setFilterDepth(e.target.value); setCurrentPage(1); }}
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all"></option>
<option value="0">1</option>
<option value="1">2</option>
<option value="2">3</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); setCurrentPage(1); }}
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all"></option>
<option value="active"></option>
<option value="inactive"></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); setCurrentPage(1); }}
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all"></option>
<option value="top"></option>
{parentDepartments.map(dept => (
<option key={dept.id} value={dept.id}>{dept.name}</option>
))}
</select>
</div>
</div>
</div>
{/* Pagination Controls */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-700"></span>
<select
value={pageSize}
onChange={(e) => { setPageSize(parseInt(e.target.value)); setCurrentPage(1); }}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="5">5</option>
<option value="10">10</option>
<option value="15">15</option>
<option value="20">20</option>
<option value="25">25</option>
<option value="50">50</option>
</select>
<span className="text-sm text-gray-700"></span>
</div>
<div className="text-sm text-gray-700">
{filteredDepartments.length}
</div>
</div>
{/* DataTable */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-blue-900">
<tr>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-white uppercase tracking-wider">ID</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-white uppercase tracking-wider"></th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-white uppercase tracking-wider"></th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-white uppercase tracking-wider"></th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-white uppercase tracking-wider"></th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-white uppercase tracking-wider"></th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-white uppercase tracking-wider"></th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-white uppercase tracking-wider"></th>
<th className="px-4 py-2.5 text-center text-xs font-semibold text-white uppercase tracking-wider"></th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{loading ? (
<tr>
<td colSpan={9} className="px-4 py-8 text-center text-sm text-gray-500">
...
</td>
</tr>
) : paginatedDepartments.length === 0 ? (
<tr>
<td colSpan={9} className="px-4 py-8 text-center text-sm text-gray-500">
</td>
</tr>
) : (
paginatedDepartments.map((dept) => (
<tr key={dept.id} className="hover:bg-gray-50">
<td className="px-4 py-2.5 text-sm text-gray-900">{dept.id}</td>
<td className="px-4 py-2.5 text-sm text-gray-900">{getDepthLabel(dept.depth)}</td>
<td className="px-4 py-2.5 text-sm text-gray-900 font-medium">{dept.code}</td>
<td className="px-4 py-2.5 text-sm text-gray-900">{dept.name}</td>
<td className="px-4 py-2.5 text-sm text-gray-600">{getParentName(dept.parent_id)}</td>
<td className="px-4 py-2.5 text-sm text-gray-600">{dept.effective_email_domain || '-'}</td>
<td className="px-4 py-2.5 text-sm text-gray-900">{dept.member_count || 0}</td>
<td className="px-4 py-2.5">
<button
onClick={() => handleToggleActive(dept)}
className={`px-2 py-1 rounded text-xs font-medium cursor-pointer ${
dept.is_active
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-red-100 text-red-800 hover:bg-red-200'
}`}
>
{dept.is_active ? '是' : '否'}
</button>
</td>
<td className="px-4 py-2.5 text-center">
<div className="flex justify-center gap-2">
<button
onClick={() => handleEdit(dept)}
className="px-3 py-1 text-xs font-medium text-blue-600 hover:text-blue-800 border border-blue-600 hover:border-blue-800 rounded transition-colors"
>
</button>
<button
onClick={() => handleDelete(dept)}
className="px-3 py-1 text-xs font-medium text-red-600 hover:text-red-800 border border-red-600 hover:border-red-800 rounded transition-colors"
>
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="bg-gray-50 px-4 py-3 flex items-center justify-between border-t border-gray-200">
<div className="text-sm text-gray-700">
{startIndex + 1} - {Math.min(startIndex + pageSize, filteredDepartments.length)} {filteredDepartments.length}
</div>
<div className="flex gap-2">
<button
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
«
</button>
<button
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
className="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
{[...Array(totalPages)].map((_, i) => (
<button
key={i + 1}
onClick={() => setCurrentPage(i + 1)}
className={`px-3 py-1 text-sm border rounded ${
currentPage === i + 1
? 'bg-blue-600 text-white border-blue-600'
: 'border-gray-300 hover:bg-gray-100'
}`}
>
{i + 1}
</button>
))}
<button
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<button
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages}
className="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
»
</button>
</div>
</div>
)}
</div>
{/* Modal */}
{showModal && (
<DepartmentFormModal
department={editingDepartment}
parentDepartments={parentDepartments}
onClose={() => setShowModal(false)}
onSave={(isEdit: boolean) => {
setShowModal(false)
loadDepartments()
loadParentDepartments()
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}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog({ ...confirmDialog, isOpen: false })}
/>
</div>
)
}