Files
hr-portal/frontend/app/employees/new/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

578 lines
21 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 { useRouter } from 'next/navigation'
import { apiClient } from '@/lib/api-client'
// 第一層部門 (depth=0擁有 email_domain)
interface TopLevelDepartment {
id: number
name: string
code: string
email_domain?: string
effective_email_domain?: string
depth: number
is_active: boolean
}
// 子部門 (depth>=1)
interface SubDepartment {
id: number
name: string
code: string
parent_id: number
depth: number
effective_email_domain?: string
}
interface EmployeeFormData {
username_base: string
legal_name: string
english_name: string
phone: string
mobile: string
hire_date: string
// 組織與職務資訊 (新多層部門架構)
top_department_id: string // 第一層部門 (決定郵件網域)
department_id: string // 指定部門 (選填,可為任何層)
job_title: string
email_quota_mb: string
// 到職自動化
auto_onboard: boolean
create_keycloak: boolean
create_email: boolean
create_drive: boolean
}
interface OnboardResult {
created?: boolean
disabled?: boolean
error?: string | null
message?: string
username?: string
email?: string
user_id?: string
quota_gb?: number
}
export default function NewEmployeePage() {
const { data: session, status } = useSession()
const router = useRouter()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [topDepartments, setTopDepartments] = useState<TopLevelDepartment[]>([])
const [subDepartments, setSubDepartments] = useState<SubDepartment[]>([])
const [loadingSubDepts, setLoadingSubDepts] = useState(false)
const [onboardResults, setOnboardResults] = useState<{
keycloak: OnboardResult
email: OnboardResult
drive: OnboardResult
} | null>(null)
const [formData, setFormData] = useState<EmployeeFormData>({
username_base: '',
legal_name: '',
english_name: '',
phone: '',
mobile: '',
hire_date: new Date().toISOString().split('T')[0],
top_department_id: '',
department_id: '',
job_title: '',
email_quota_mb: '5120',
auto_onboard: true,
create_keycloak: true,
create_email: true,
create_drive: true,
})
// 載入第一層部門列表
useEffect(() => {
if (status === 'authenticated') {
fetchTopDepartments()
}
}, [status])
// 當選擇第一層部門時,載入其子部門
useEffect(() => {
if (formData.top_department_id) {
fetchSubDepartments(parseInt(formData.top_department_id))
} else {
setSubDepartments([])
setFormData((prev) => ({ ...prev, department_id: '' }))
}
}, [formData.top_department_id])
const fetchTopDepartments = async () => {
try {
const data = await apiClient.get<TopLevelDepartment[]>('/departments/?depth=0')
setTopDepartments(data)
} catch (err) {
console.error('載入部門失敗:', err)
}
}
const fetchSubDepartments = async (parentId: number) => {
try {
setLoadingSubDepts(true)
const data = await apiClient.get<SubDepartment[]>(`/departments/?parent_id=${parentId}`)
setSubDepartments(data)
} catch (err) {
console.error('載入子部門失敗:', err)
setSubDepartments([])
} finally {
setLoadingSubDepts(false)
}
}
if (status === 'loading') {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg">...</div>
</div>
)
}
if (status === 'unauthenticated') {
router.push('/auth/signin')
return null
}
const selectedTopDept = topDepartments.find(
(d) => d.id === parseInt(formData.top_department_id)
)
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value, type } = e.target
const checked = (e.target as HTMLInputElement).checked
setFormData((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
// 決定 department_id若有選子部門用子部門否則用第一層部門
const finalDepartmentId = formData.department_id
? parseInt(formData.department_id)
: formData.top_department_id
? parseInt(formData.top_department_id)
: undefined
const employeePayload = {
username_base: formData.username_base,
legal_name: formData.legal_name,
english_name: formData.english_name || undefined,
phone: formData.phone || undefined,
mobile: formData.mobile || undefined,
hire_date: formData.hire_date,
// 新架構department_id 指向任何層部門
department_id: finalDepartmentId,
job_title: formData.job_title,
email_quota_mb: parseInt(formData.email_quota_mb),
}
const newEmployee = await apiClient.post('/employees/', employeePayload) as any
const newEmployeeId = newEmployee.id
// 自動執行到職流程
if (formData.auto_onboard) {
try {
const params = new URLSearchParams({
create_keycloak: String(formData.create_keycloak),
create_email: String(formData.create_email),
create_drive: String(formData.create_drive),
})
const onboardResponse = await apiClient.post(
`/employees/${newEmployeeId}/onboard?${params.toString()}`,
{}
) as any
setOnboardResults(onboardResponse.results)
setTimeout(() => {
router.push(`/employees/${newEmployeeId}`)
}, 3000)
} catch {
router.push(`/employees/${newEmployeeId}`)
}
} else {
router.push(`/employees/${newEmployeeId}`)
}
} catch (err: any) {
const errorMessage = err?.response?.data?.detail || err.message || '新增失敗'
setError(errorMessage)
setLoading(false)
}
}
return (
<div className="container mx-auto px-4 py-8 max-w-2xl">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900"></h1>
<p className="mt-2 text-gray-600"></p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
)}
{/* 到職流程結果顯示 */}
{onboardResults && (
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<h3 className="text-sm font-medium text-green-900 mb-3">
</h3>
<div className="space-y-2">
{formData.create_keycloak && (
<div className={`flex items-center gap-2 text-sm ${onboardResults.keycloak?.created ? 'text-green-700' : 'text-amber-700'}`}>
<span>{onboardResults.keycloak?.created ? '✓' : '⚠'}</span>
<span>Keycloak SSO: {onboardResults.keycloak?.message || (onboardResults.keycloak?.error ? `失敗 - ${onboardResults.keycloak.error}` : '未執行')}</span>
</div>
)}
{formData.create_email && (
<div className={`flex items-center gap-2 text-sm ${onboardResults.email?.created ? 'text-green-700' : 'text-amber-700'}`}>
<span>{onboardResults.email?.created ? '✓' : '⚠'}</span>
<span>: {onboardResults.email?.message || (onboardResults.email?.error ? `失敗 - ${onboardResults.email.error}` : '未執行')}</span>
</div>
)}
{formData.create_drive && (
<div className={`flex items-center gap-2 text-sm ${onboardResults.drive?.created ? 'text-green-700' : 'text-amber-700'}`}>
<span>{onboardResults.drive?.created ? '✓' : '⚠'}</span>
<span>: {onboardResults.drive?.message || (onboardResults.drive?.error ? `失敗 - ${onboardResults.drive.error}` : '未執行')}</span>
</div>
)}
</div>
<p className="mt-3 text-xs text-green-600">3 ...</p>
</div>
)}
<form onSubmit={handleSubmit} className="bg-white shadow rounded-lg p-6">
<div className="space-y-6">
{/* 單一登入帳號 */}
<div>
<label htmlFor="username_base" className="block text-sm font-medium text-gray-700">
(SSO) <span className="text-red-500">*</span>
</label>
<p className="mt-1 text-sm text-gray-500">
SSO
<br />
例如: 輸入 <code className="bg-gray-100 px-1 rounded">porsche.chen</code>
<code className="bg-gray-100 px-1 rounded">porsche.chen@ease.taipei</code>
</p>
<input
type="text"
id="username_base"
name="username_base"
required
value={formData.username_base}
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 text-gray-900"
placeholder="firstname.lastname"
/>
</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 text-gray-900"
placeholder="陳保時"
/>
</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 text-gray-900"
placeholder="Porsche Chen"
/>
</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 text-gray-900"
placeholder="02-12345678"
/>
</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 text-gray-900"
placeholder="0912-345678"
/>
</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 text-gray-900"
/>
</div>
{/* 分隔線 */}
<div className="border-t border-gray-200 pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">
</h3>
</div>
{/* 第一層部門 (原事業部) */}
<div>
<label htmlFor="top_department_id" className="block text-sm font-medium text-gray-700">
() <span className="text-red-500">*</span>
</label>
<select
id="top_department_id"
name="top_department_id"
required
value={formData.top_department_id}
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 text-gray-900"
>
<option value=""></option>
{topDepartments.map((dept) => (
<option key={dept.id} value={dept.id}>
{dept.name}
{dept.email_domain ? ` (@${dept.email_domain})` : ''}
</option>
))}
</select>
<p className="mt-1 text-sm text-gray-500">
{selectedTopDept?.email_domain && (
<span className="ml-1 font-mono text-blue-600">
@{selectedTopDept.email_domain}
</span>
)}
</p>
</div>
{/* 子部門 (選填) */}
<div>
<label htmlFor="department_id" className="block text-sm font-medium text-gray-700">
()
</label>
<select
id="department_id"
name="department_id"
value={formData.department_id}
onChange={handleChange}
disabled={!formData.top_department_id || loadingSubDepts}
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 text-gray-900 disabled:bg-gray-100 disabled:cursor-not-allowed disabled:text-gray-500"
>
<option value="">
{!formData.top_department_id
? '請先選擇第一層部門'
: loadingSubDepts
? '載入中...'
: subDepartments.length > 0
? '請選擇子部門 (選填)'
: '此部門尚無子部門'}
</option>
{subDepartments.map((dept) => (
<option key={dept.id} value={dept.id}>
{dept.name} ({dept.code})
</option>
))}
</select>
<p className="mt-1 text-sm text-gray-500">
</p>
</div>
{/* 職稱 */}
<div>
<label htmlFor="job_title" className="block text-sm font-medium text-gray-700">
<span className="text-red-500">*</span>
</label>
<input
type="text"
id="job_title"
name="job_title"
required
value={formData.job_title}
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 text-gray-900"
placeholder="例如: 軟體工程師、技術總監"
/>
</div>
{/* 郵件配額 */}
<div>
<label htmlFor="email_quota_mb" className="block text-sm font-medium text-gray-700">
(MB) <span className="text-red-500">*</span>
</label>
<input
type="number"
id="email_quota_mb"
name="email_quota_mb"
required
min="1024"
max="51200"
step="1024"
value={formData.email_quota_mb}
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 text-gray-900"
/>
<p className="mt-1 text-sm text-gray-500">
5120 MB (5 GB) 1024 MB 51200 MB
</p>
</div>
</div>
{/* 到職自動化流程 */}
<div className="border-t border-gray-200 pt-6 mt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">
</h3>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
name="auto_onboard"
checked={formData.auto_onboard}
onChange={handleChange}
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
/>
<span className="text-sm font-medium text-gray-700"></span>
</label>
</div>
{formData.auto_onboard && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-3">
<p className="text-sm text-blue-700 font-medium"></p>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
name="create_keycloak"
checked={formData.create_keycloak}
onChange={handleChange}
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
/>
<div>
<span className="text-sm font-medium text-gray-700">Keycloak SSO </span>
<p className="text-xs text-gray-500"> SSO ()</p>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
name="create_email"
checked={formData.create_email}
onChange={handleChange}
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
/>
<div>
<span className="text-sm font-medium text-gray-700"></span>
<p className="text-xs text-gray-500"></p>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
name="create_drive"
checked={formData.create_drive}
onChange={handleChange}
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
/>
<div>
<span className="text-sm font-medium text-gray-700"></span>
<p className="text-xs text-gray-500"> Nextcloud (Drive Service )</p>
</div>
</label>
</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={loading}
>
</button>
<button
type="submit"
disabled={loading}
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"
>
{loading ? '建立中...' : '建立員工'}
</button>
</div>
</form>
{/* 提示訊息 */}
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h3 className="text-sm font-medium text-blue-900 mb-2">
💡
</h3>
<ul className="text-sm text-blue-700 space-y-1">
<li> (EMP001, EMP002...)</li>
<li> </li>
<li> SSO </li>
<li> </li>
<li> :</li>
<li className="ml-4">- Keycloak SSO ()</li>
<li className="ml-4">- </li>
<li className="ml-4">- (Drive Service)</li>
<li> </li>
</ul>
</div>
</div>
)
}