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,304 @@
'use client'
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
interface Employee {
id: number
employee_id: string
username_base: string
legal_name: string
english_name: string | null
phone: string | null
mobile: string | null
hire_date: string
termination_date: string | null
status: string
identities_count?: number
has_network_drive?: boolean
// Phase 2.3: 主要身份資訊
primary_business_unit?: string | null
primary_department?: string | null
primary_job_title?: string | null
}
interface EmployeeListResponse {
total: number
page: number
page_size: number
total_pages: number
items: Employee[]
}
export default function EmployeesPage() {
const { data: session, status } = useSession()
const router = useRouter()
const [employees, setEmployees] = useState<Employee[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [pagination, setPagination] = useState({
total: 0,
page: 1,
page_size: 20,
total_pages: 0,
})
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('')
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/auth/signin')
}
}, [status, router])
useEffect(() => {
if (status === 'authenticated') {
fetchEmployees()
}
}, [status, pagination.page, search, statusFilter])
const fetchEmployees = async () => {
try {
setLoading(true)
setError(null)
const params = new URLSearchParams({
page: pagination.page.toString(),
page_size: pagination.page_size.toString(),
})
if (search) params.append('search', search)
if (statusFilter) params.append('status_filter', statusFilter)
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/employees/?${params.toString()}`
)
if (!response.ok) {
throw new Error('無法載入員工列表')
}
const data: EmployeeListResponse = await response.json()
setEmployees(data.items)
setPagination({
total: data.total,
page: data.page,
page_size: data.page_size,
total_pages: data.total_pages,
})
} catch (err) {
setError(err instanceof Error ? err.message : '載入失敗')
} finally {
setLoading(false)
}
}
const handleSearch = (value: string) => {
setSearch(value)
setPagination({ ...pagination, page: 1 })
}
const handleStatusFilter = (value: string) => {
setStatusFilter(value)
setPagination({ ...pagination, page: 1 })
}
const handlePageChange = (newPage: number) => {
setPagination({ ...pagination, page: newPage })
}
if (status === 'loading' || loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg">...</div>
</div>
)
}
if (!session) {
return null
}
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900"></h1>
<p className="mt-2 text-gray-600"></p>
</div>
{/* 搜尋與篩選 */}
<div className="mb-6 flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<input
type="text"
placeholder="搜尋姓名、工號或帳號..."
value={search}
onChange={(e) => handleSearch(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<select
value={statusFilter}
onChange={(e) => handleStatusFilter(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value=""></option>
<option value="active"></option>
<option value="on_leave"></option>
<option value="terminated"></option>
</select>
</div>
<button
onClick={() => router.push('/employees/new')}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
+
</button>
</div>
{/* 錯誤訊息 */}
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
)}
{/* 員工列表 */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
/
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{employees.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-gray-500">
</td>
</tr>
) : (
employees.map((employee) => (
<tr key={employee.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{employee.employee_id}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{employee.legal_name}
</div>
{employee.english_name && (
<div className="text-sm text-gray-500">
{employee.english_name}
</div>
)}
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900">
{employee.primary_business_unit || '-'}
</div>
{employee.primary_department && (
<div className="text-xs text-gray-500">
{employee.primary_department}
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{employee.primary_job_title || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(employee.hire_date).toLocaleDateString('zh-TW')}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
employee.status === 'active'
? 'bg-green-100 text-green-800'
: employee.status === 'on_leave'
? 'bg-yellow-100 text-yellow-800'
: 'bg-red-100 text-red-800'
}`}
>
{employee.status === 'active'
? '在職'
: employee.status === 'on_leave'
? '留停'
: '離職'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => router.push(`/employees/${employee.id}`)}
className="text-blue-600 hover:text-blue-900 mr-4"
>
</button>
<button
onClick={() => router.push(`/employees/${employee.id}/edit`)}
className="text-indigo-600 hover:text-indigo-900"
>
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* 分頁 */}
{pagination.total_pages > 1 && (
<div className="mt-6 flex items-center justify-between">
<div className="text-sm text-gray-700">
{(pagination.page - 1) * pagination.page_size + 1} -{' '}
{Math.min(pagination.page * pagination.page_size, pagination.total)}
{pagination.total}
</div>
<div className="flex gap-2">
<button
onClick={() => handlePageChange(pagination.page - 1)}
disabled={pagination.page === 1}
className="px-4 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
</button>
<span className="px-4 py-2 border border-gray-300 rounded-lg bg-white">
{pagination.page} / {pagination.total_pages}
</span>
<button
onClick={() => handlePageChange(pagination.page + 1)}
disabled={pagination.page === pagination.total_pages}
className="px-4 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
</button>
</div>
</div>
)}
</div>
)
}