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,322 @@
'use client'
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter, useParams } 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
status: string
}
interface EmployeeUpdateData {
legal_name?: string
english_name?: string
phone?: string
mobile?: string
hire_date?: string
status?: string
}
export default function EditEmployeePage() {
const { data: session, status } = useSession()
const router = useRouter()
const params = useParams()
const employeeId = params.id as string
const [employee, setEmployee] = useState<Employee | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [formData, setFormData] = useState<EmployeeUpdateData>({})
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/auth/signin')
}
}, [status, router])
useEffect(() => {
if (status === 'authenticated' && employeeId) {
fetchEmployee()
}
}, [status, employeeId])
const fetchEmployee = async () => {
try {
setLoading(true)
setError(null)
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/employees/${employeeId}`
)
if (!response.ok) {
throw new Error('無法載入員工資料')
}
const data: Employee = await response.json()
setEmployee(data)
setFormData({
legal_name: data.legal_name,
english_name: data.english_name || '',
phone: data.phone || '',
mobile: data.mobile || '',
hire_date: data.hire_date.split('T')[0],
status: data.status,
})
} catch (err) {
setError(err instanceof Error ? err.message : '載入失敗')
} finally {
setLoading(false)
}
}
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value } = e.target
setFormData((prev) => ({
...prev,
[name]: value || undefined,
}))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
setError(null)
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/employees/${employeeId}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
}
)
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.detail || '更新員工失敗')
}
// 成功後導向員工詳情頁
router.push(`/employees/${employeeId}`)
} catch (err) {
setError(err instanceof Error ? err.message : '更新失敗')
setSaving(false)
}
}
if (status === 'loading' || loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg">...</div>
</div>
)
}
if (!session || !employee) {
return null
}
return (
<div className="container mx-auto px-4 py-8 max-w-2xl">
<div className="mb-8">
<button
onClick={() => router.back()}
className="text-blue-600 hover:text-blue-800 mb-2"
>
</button>
<h1 className="text-3xl font-bold text-gray-900"></h1>
<p className="mt-2 text-gray-600">
{employee.employee_id} - {employee.legal_name}
</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="bg-white shadow rounded-lg p-6">
<div className="space-y-6">
{/* 員工編號 (唯讀) */}
<div>
<label className="block text-sm font-medium text-gray-700">
</label>
<input
type="text"
value={employee.employee_id}
disabled
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-gray-500"
/>
</div>
{/* 帳號基底 (唯讀) */}
<div>
<label className="block text-sm font-medium text-gray-700">
</label>
<input
type="text"
value={employee.username_base}
disabled
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-gray-500 font-mono"
/>
<p className="mt-1 text-sm text-gray-500">
</p>
</div>
{/* 中文姓名 */}
<div>
<label
htmlFor="legal_name"
className="block text-sm font-medium text-gray-700"
>
<span className="text-red-500">*</span>
</label>
<input
type="text"
id="legal_name"
name="legal_name"
required
value={formData.legal_name}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* 英文姓名 */}
<div>
<label
htmlFor="english_name"
className="block text-sm font-medium text-gray-700"
>
</label>
<input
type="text"
id="english_name"
name="english_name"
value={formData.english_name}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* 電話 */}
<div>
<label
htmlFor="phone"
className="block text-sm font-medium text-gray-700"
>
</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* 手機 */}
<div>
<label
htmlFor="mobile"
className="block text-sm font-medium text-gray-700"
>
</label>
<input
type="tel"
id="mobile"
name="mobile"
value={formData.mobile}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* 到職日 */}
<div>
<label
htmlFor="hire_date"
className="block text-sm font-medium text-gray-700"
>
<span className="text-red-500">*</span>
</label>
<input
type="date"
id="hire_date"
name="hire_date"
required
value={formData.hire_date}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* 狀態 */}
<div>
<label
htmlFor="status"
className="block text-sm font-medium text-gray-700"
>
<span className="text-red-500">*</span>
</label>
<select
id="status"
name="status"
required
value={formData.status}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
>
<option value="active"></option>
<option value="on_leave"></option>
<option value="terminated"></option>
</select>
</div>
</div>
{/* 按鈕區 */}
<div className="mt-8 flex justify-end gap-4">
<button
type="button"
onClick={() => router.back()}
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
disabled={saving}
>
</button>
<button
type="submit"
disabled={saving}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? '儲存中...' : '儲存變更'}
</button>
</div>
</form>
</div>
)
}