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:
304
frontend/app/employees/page.tsx
Normal file
304
frontend/app/employees/page.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } 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
|
||||
termination_date: string | null
|
||||
status: string
|
||||
identities_count?: number
|
||||
has_network_drive?: boolean
|
||||
// Phase 2.3: 主要身份資訊
|
||||
primary_business_unit?: string | null
|
||||
primary_department?: string | null
|
||||
primary_job_title?: string | null
|
||||
}
|
||||
|
||||
interface EmployeeListResponse {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
total_pages: number
|
||||
items: Employee[]
|
||||
}
|
||||
|
||||
export default function EmployeesPage() {
|
||||
const { data: session, status } = useSession()
|
||||
const router = useRouter()
|
||||
const [employees, setEmployees] = useState<Employee[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pagination, setPagination] = useState({
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total_pages: 0,
|
||||
})
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'unauthenticated') {
|
||||
router.push('/auth/signin')
|
||||
}
|
||||
}, [status, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'authenticated') {
|
||||
fetchEmployees()
|
||||
}
|
||||
}, [status, pagination.page, search, statusFilter])
|
||||
|
||||
const fetchEmployees = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: pagination.page.toString(),
|
||||
page_size: pagination.page_size.toString(),
|
||||
})
|
||||
|
||||
if (search) params.append('search', search)
|
||||
if (statusFilter) params.append('status_filter', statusFilter)
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL}/employees/?${params.toString()}`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('無法載入員工列表')
|
||||
}
|
||||
|
||||
const data: EmployeeListResponse = await response.json()
|
||||
setEmployees(data.items)
|
||||
setPagination({
|
||||
total: data.total,
|
||||
page: data.page,
|
||||
page_size: data.page_size,
|
||||
total_pages: data.total_pages,
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '載入失敗')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearch(value)
|
||||
setPagination({ ...pagination, page: 1 })
|
||||
}
|
||||
|
||||
const handleStatusFilter = (value: string) => {
|
||||
setStatusFilter(value)
|
||||
setPagination({ ...pagination, page: 1 })
|
||||
}
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPagination({ ...pagination, page: newPage })
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">員工管理</h1>
|
||||
<p className="mt-2 text-gray-600">管理公司員工資料</p>
|
||||
</div>
|
||||
|
||||
{/* 搜尋與篩選 */}
|
||||
<div className="mb-6 flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜尋姓名、工號或帳號..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => handleStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">全部狀態</option>
|
||||
<option value="active">在職</option>
|
||||
<option value="on_leave">留職停薪</option>
|
||||
<option value="terminated">離職</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/employees/new')}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
+ 新增員工
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 錯誤訊息 */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 員工列表 */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
工號
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
姓名
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
事業部/部門
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
職稱
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
到職日
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
狀態
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{employees.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-8 text-center text-gray-500">
|
||||
暫無員工資料
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
employees.map((employee) => (
|
||||
<tr key={employee.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{employee.employee_id}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{employee.legal_name}
|
||||
</div>
|
||||
{employee.english_name && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{employee.english_name}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-gray-900">
|
||||
{employee.primary_business_unit || '-'}
|
||||
</div>
|
||||
{employee.primary_department && (
|
||||
<div className="text-xs text-gray-500">
|
||||
{employee.primary_department}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{employee.primary_job_title || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(employee.hire_date).toLocaleDateString('zh-TW')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
employee.status === 'active'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: employee.status === 'on_leave'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{employee.status === 'active'
|
||||
? '在職'
|
||||
: employee.status === 'on_leave'
|
||||
? '留停'
|
||||
: '離職'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
onClick={() => router.push(`/employees/${employee.id}`)}
|
||||
className="text-blue-600 hover:text-blue-900 mr-4"
|
||||
>
|
||||
查看
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push(`/employees/${employee.id}/edit`)}
|
||||
className="text-indigo-600 hover:text-indigo-900"
|
||||
>
|
||||
編輯
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 分頁 */}
|
||||
{pagination.total_pages > 1 && (
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700">
|
||||
顯示第 {(pagination.page - 1) * pagination.page_size + 1} -{' '}
|
||||
{Math.min(pagination.page * pagination.page_size, pagination.total)} 筆,
|
||||
共 {pagination.total} 筆
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handlePageChange(pagination.page - 1)}
|
||||
disabled={pagination.page === 1}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
上一頁
|
||||
</button>
|
||||
<span className="px-4 py-2 border border-gray-300 rounded-lg bg-white">
|
||||
第 {pagination.page} / {pagination.total_pages} 頁
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handlePageChange(pagination.page + 1)}
|
||||
disabled={pagination.page === pagination.total_pages}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
下一頁
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user