Files
hr-portal/frontend/app/employees/[id]/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

645 lines
26 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 { 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>
)
}