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

305 lines
10 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 } 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>
)
}