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:
322
frontend/app/employees/[id]/edit/page.tsx
Normal file
322
frontend/app/employees/[id]/edit/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user