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,644 @@
'use client'
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter, useParams } from 'next/navigation'
import EmailAccountsTab from '@/components/employees/email-accounts-tab'
import PermissionsTab from '@/components/employees/permissions-tab'
interface DepartmentMembership {
id: number
department_id: number
department_name: string
department_code: string
department_depth: number
effective_email_domain?: string
position?: string
membership_type: string
is_active: boolean
joined_at: string
ended_at?: string
}
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
has_network_drive: boolean
created_at: string
updated_at: string
}
type TabType = 'basic' | 'departments' | 'email' | 'permissions'
interface OffboardResult {
disabled?: boolean
handled?: boolean
created?: boolean
error?: string | null
message?: string
method?: string
}
export default function EmployeeDetailPage() {
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 [memberships, setMemberships] = useState<DepartmentMembership[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<TabType>('basic')
// 離職處理 Dialog 狀態
const [showOffboardDialog, setShowOffboardDialog] = useState(false)
const [offboardConfirmText, setOffboardConfirmText] = useState('')
const [offboardLoading, setOffboardLoading] = useState(false)
const [offboardEmailHandling, setOffboardEmailHandling] = useState<'forward' | 'disable'>('forward')
const [offboardResults, setOffboardResults] = useState<{
keycloak: OffboardResult
email: OffboardResult
drive: OffboardResult
} | null>(null)
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/auth/signin')
}
}, [status, router])
useEffect(() => {
if (status === 'authenticated' && employeeId) {
fetchEmployee()
fetchMemberships()
}
}, [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 = await response.json()
setEmployee(data)
} catch (err) {
setError(err instanceof Error ? err.message : '載入失敗')
} finally {
setLoading(false)
}
}
const fetchMemberships = async () => {
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/department-members/?employee_id=${employeeId}`
)
if (response.ok) {
const data = await response.json()
setMemberships(Array.isArray(data) ? data : data.items || [])
}
} catch (err) {
console.error('載入部門成員資料失敗:', err)
}
}
// 執行離職流程
const handleOffboard = async () => {
if (!employee) return
if (offboardConfirmText !== employee.employee_id) return
setOffboardLoading(true)
try {
const searchParams = new URLSearchParams({
disable_keycloak: 'true',
email_handling: offboardEmailHandling,
disable_drive: 'true',
})
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/employees/${employeeId}/offboard?${searchParams.toString()}`,
{ method: 'POST' }
)
if (!response.ok) {
const data = await response.json()
throw new Error(data.detail || '離職流程執行失敗')
}
const data = await response.json()
setOffboardResults(data.results)
setEmployee((prev) => prev ? { ...prev, status: 'terminated' } : null)
} catch (err) {
alert(err instanceof Error ? err.message : '操作失敗')
} finally {
setOffboardLoading(false)
}
}
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
if (error) {
return (
<div className="container mx-auto px-4 py-8">
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
<button
onClick={() => router.back()}
className="mt-4 text-blue-600 hover:text-blue-800"
>
</button>
</div>
)
}
if (!employee) return null
const tabs: { id: TabType; label: string; icon: string }[] = [
{ id: 'basic', label: '基本資料', icon: '👤' },
{ id: 'departments', label: '部門成員', icon: '🏢' },
{ id: 'email', label: '郵件帳號', icon: '📧' },
{ id: 'permissions', label: '系統權限', icon: '🔐' },
]
// 找到主要郵件網域 (取第一個啟用中的成員紀錄的有效網域)
const primaryMembership = memberships.find((m) => m.is_active)
const primaryEmailDomain = primaryMembership?.effective_email_domain
const membershipTypeLabel = (type: string) => {
switch (type) {
case 'permanent': return '正式'
case 'temporary': return '臨時'
case 'project': return '專案'
default: return type
}
}
return (
<div className="container mx-auto px-4 py-8 max-w-6xl">
{/* 頁首 */}
<div className="mb-8 flex items-center justify-between">
<div>
<button
onClick={() => router.back()}
className="text-blue-600 hover:text-blue-800 mb-2"
>
</button>
<h1 className="text-3xl font-bold text-gray-900">
{employee.legal_name}
</h1>
<p className="mt-1 text-gray-600">: {employee.employee_id}</p>
</div>
<div className="flex gap-2">
<button
onClick={() => router.push(`/employees/${employee.id}/edit`)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
</button>
{employee.status === 'active' && (
<button
onClick={() => {
setOffboardConfirmText('')
setOffboardResults(null)
setShowOffboardDialog(true)
}}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
</button>
)}
</div>
</div>
{/* Tab 切換 */}
<div className="mb-6">
<div className="border-b border-gray-200">
<nav className="-mb-px flex gap-8">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`
py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2
${
activeTab === tab.id
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}
`}
>
<span>{tab.icon}</span>
{tab.label}
{tab.id === 'departments' && memberships.length > 0 && (
<span className="ml-1 bg-blue-100 text-blue-700 text-xs px-1.5 py-0.5 rounded-full">
{memberships.filter((m) => m.is_active).length}
</span>
)}
</button>
))}
</nav>
</div>
</div>
{/* 基本資料 Tab */}
{activeTab === 'basic' && (
<div>
<div className="bg-white shadow rounded-lg p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-500"></label>
<p className="mt-1 text-lg text-gray-900">{employee.employee_id}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500"></label>
<span
className={`mt-1 inline-flex px-2 py-1 text-sm font-semibold rounded-full ${
employee.status === 'active'
? 'bg-green-100 text-green-800'
: employee.status === 'inactive'
? 'bg-yellow-100 text-yellow-800'
: 'bg-red-100 text-red-800'
}`}
>
{employee.status === 'active' ? '在職' : employee.status === 'inactive' ? '停用' : '離職'}
</span>
</div>
<div>
<label className="block text-sm font-medium text-gray-500"></label>
<p className="mt-1 text-lg text-gray-900">{employee.legal_name}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500"></label>
<p className="mt-1 text-lg text-gray-900">{employee.english_name || '-'}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500"></label>
<p className="mt-1 text-lg text-gray-900 font-mono">{employee.username_base}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500"></label>
<p className="mt-1 text-lg text-gray-900">
{new Date(employee.hire_date).toLocaleDateString('zh-TW')}
</p>
</div>
{employee.termination_date && (
<div>
<label className="block text-sm font-medium text-gray-500"></label>
<p className="mt-1 text-lg text-gray-900">
{new Date(employee.termination_date).toLocaleDateString('zh-TW')}
</p>
</div>
)}
</div>
</div>
{/* 聯絡資訊 */}
<div className="bg-white shadow rounded-lg p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-500"></label>
<p className="mt-1 text-lg text-gray-900">{employee.phone || '-'}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500"></label>
<p className="mt-1 text-lg text-gray-900">{employee.mobile || '-'}</p>
</div>
</div>
</div>
{/* 系統帳號資訊 */}
<div className="bg-white shadow rounded-lg p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<p className="text-sm font-medium text-gray-500"></p>
<p className="mt-1 text-2xl font-semibold text-gray-900">
{memberships.filter((m) => m.is_active).length}
</p>
<p className="text-xs text-gray-500 mt-1">
{memberships.filter((m) => m.is_active).length > 1 ? '多部門任職' : '單一部門'}
</p>
</div>
<button
onClick={() => setActiveTab('departments')}
className="px-3 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 transition-colors"
>
</button>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<p className="text-sm font-medium text-gray-500">NAS </p>
<p className="mt-1 text-lg font-semibold text-gray-900">
{employee.has_network_drive ? '已建立' : '未建立'}
</p>
</div>
{employee.has_network_drive && (
<span className="text-green-600"></span>
)}
</div>
</div>
</div>
{/* 系統資訊 */}
<div className="bg-gray-50 rounded-lg p-4 text-sm text-gray-600">
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<div>: {new Date(employee.created_at).toLocaleString('zh-TW')}</div>
<div>: {new Date(employee.updated_at).toLocaleString('zh-TW')}</div>
</div>
</div>
</div>
)}
{/* 部門成員 Tab */}
{activeTab === 'departments' && (
<div>
<div className="bg-white shadow rounded-lg p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900"></h2>
<p className="text-sm text-gray-500">
</p>
</div>
{memberships.length === 0 ? (
<div className="text-center py-8">
<p className="text-gray-500"></p>
<p className="text-sm text-gray-400 mt-1">
</p>
</div>
) : (
<div className="space-y-3">
{memberships.map((membership) => (
<div
key={membership.id}
className={`border rounded-lg p-4 ${
membership.is_active
? 'border-blue-200 bg-blue-50'
: 'border-gray-200 bg-gray-50 opacity-60'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900">
{membership.department_name}
</span>
<span className="text-xs text-gray-500 bg-gray-200 px-2 py-0.5 rounded">
{membership.department_code}
</span>
{membership.department_depth === 0 && (
<span className="text-xs text-blue-600 bg-blue-100 px-2 py-0.5 rounded">
</span>
)}
<span
className={`text-xs px-2 py-0.5 rounded ${
membership.is_active
? 'text-green-700 bg-green-100'
: 'text-gray-600 bg-gray-200'
}`}
>
{membership.is_active ? '啟用中' : '已結束'}
</span>
</div>
<div className="flex flex-wrap gap-4 text-sm text-gray-600 mt-2">
{membership.position && (
<div>
<span className="text-gray-400">: </span>
<span>{membership.position}</span>
</div>
)}
<div>
<span className="text-gray-400">: </span>
<span>{membershipTypeLabel(membership.membership_type)}</span>
</div>
{membership.effective_email_domain && (
<div>
<span className="text-gray-400">: </span>
<span className="font-mono text-blue-600">
@{membership.effective_email_domain}
</span>
</div>
)}
</div>
<div className="flex flex-wrap gap-4 text-xs text-gray-500 mt-1">
<div>
: {new Date(membership.joined_at).toLocaleDateString('zh-TW')}
</div>
{membership.ended_at && (
<div>
: {new Date(membership.ended_at).toLocaleDateString('zh-TW')}
</div>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-700">
<p className="font-medium mb-1"></p>
<ul className="space-y-1 text-blue-600">
<li> ( + )</li>
<li> </li>
<li> </li>
<li> 調</li>
</ul>
</div>
</div>
)}
{/* 郵件帳號 Tab */}
{activeTab === 'email' && (
<EmailAccountsTab
employeeId={employee.id}
primaryIdentity={
primaryEmailDomain
? {
email_domain: primaryEmailDomain,
business_unit_name: primaryMembership?.department_name || '',
email_quota_mb: 5120,
}
: undefined
}
/>
)}
{/* 系統權限 Tab */}
{activeTab === 'permissions' && <PermissionsTab employeeId={employee.id} />}
{/* 離職處理 Dialog */}
{showOffboardDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4 p-6">
{offboardResults ? (
<div>
<h2 className="text-xl font-bold text-gray-900 mb-4"></h2>
<div className="space-y-3 mb-6">
<div className={`flex items-start gap-2 p-3 rounded-lg ${offboardResults.keycloak?.disabled ? 'bg-green-50' : 'bg-amber-50'}`}>
<span className="text-lg">{offboardResults.keycloak?.disabled ? '✓' : '⚠'}</span>
<div>
<p className="text-sm font-medium">Keycloak SSO </p>
<p className="text-xs text-gray-600">{offboardResults.keycloak?.message}</p>
{offboardResults.keycloak?.error && (
<p className="text-xs text-red-600">{offboardResults.keycloak.error}</p>
)}
</div>
</div>
<div className={`flex items-start gap-2 p-3 rounded-lg ${offboardResults.email?.handled ? 'bg-green-50' : 'bg-amber-50'}`}>
<span className="text-lg">{offboardResults.email?.handled ? '✓' : '⚠'}</span>
<div>
<p className="text-sm font-medium"> ({offboardResults.email?.method === 'forward' ? '轉發' : '停用'})</p>
<p className="text-xs text-gray-600">{offboardResults.email?.message}</p>
</div>
</div>
<div className={`flex items-start gap-2 p-3 rounded-lg ${offboardResults.drive?.disabled ? 'bg-green-50' : 'bg-amber-50'}`}>
<span className="text-lg">{offboardResults.drive?.disabled ? '✓' : '⚠'}</span>
<div>
<p className="text-sm font-medium"></p>
<p className="text-xs text-gray-600">{offboardResults.drive?.message}</p>
</div>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => {
setShowOffboardDialog(false)
router.push('/employees')
}}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
>
</button>
<button
onClick={() => setShowOffboardDialog(false)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
</button>
</div>
</div>
) : (
<div>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
<span className="text-red-600 text-xl"></span>
</div>
<h2 className="text-xl font-bold text-gray-900"></h2>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<p className="text-sm text-red-700 font-medium"></p>
<ul className="mt-2 text-sm text-red-600 space-y-1">
<li> Keycloak SSO ()</li>
<li> ()</li>
<li> </li>
<li> </li>
</ul>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="email_handling"
value="forward"
checked={offboardEmailHandling === 'forward'}
onChange={() => setOffboardEmailHandling('forward')}
className="w-4 h-4 text-blue-600"
/>
<span className="text-sm text-gray-700"> HR ()</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="email_handling"
value="disable"
checked={offboardEmailHandling === 'disable'}
onChange={() => setOffboardEmailHandling('disable')}
className="w-4 h-4 text-blue-600"
/>
<span className="text-sm text-gray-700"></span>
</label>
</div>
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
<span className="font-mono text-red-600">{employee.employee_id}</span>
</label>
<input
type="text"
value={offboardConfirmText}
onChange={(e) => setOffboardConfirmText(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-red-500 focus:border-red-500 font-mono"
placeholder={employee.employee_id}
/>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowOffboardDialog(false)}
disabled={offboardLoading}
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleOffboard}
disabled={offboardLoading || offboardConfirmText !== employee.employee_id}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{offboardLoading ? '執行中...' : '確認離職'}
</button>
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}