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