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:
322
frontend/app/employees/[id]/edit/page.tsx
Normal file
322
frontend/app/employees/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter, useParams } 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
|
||||
status: string
|
||||
}
|
||||
|
||||
interface EmployeeUpdateData {
|
||||
legal_name?: string
|
||||
english_name?: string
|
||||
phone?: string
|
||||
mobile?: string
|
||||
hire_date?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export default function EditEmployeePage() {
|
||||
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 [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [formData, setFormData] = useState<EmployeeUpdateData>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'unauthenticated') {
|
||||
router.push('/auth/signin')
|
||||
}
|
||||
}, [status, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'authenticated' && employeeId) {
|
||||
fetchEmployee()
|
||||
}
|
||||
}, [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: Employee = await response.json()
|
||||
setEmployee(data)
|
||||
setFormData({
|
||||
legal_name: data.legal_name,
|
||||
english_name: data.english_name || '',
|
||||
phone: data.phone || '',
|
||||
mobile: data.mobile || '',
|
||||
hire_date: data.hire_date.split('T')[0],
|
||||
status: data.status,
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '載入失敗')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value } = e.target
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value || undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL}/employees/${employeeId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.detail || '更新員工失敗')
|
||||
}
|
||||
|
||||
// 成功後導向員工詳情頁
|
||||
router.push(`/employees/${employeeId}`)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '更新失敗')
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'loading' || loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-lg">載入中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session || !employee) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-2xl">
|
||||
<div className="mb-8">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-blue-600 hover:text-blue-800 mb-2"
|
||||
>
|
||||
← 返回
|
||||
</button>
|
||||
<h1 className="text-3xl font-bold text-gray-900">編輯員工資料</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
{employee.employee_id} - {employee.legal_name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-white shadow rounded-lg p-6">
|
||||
<div className="space-y-6">
|
||||
{/* 員工編號 (唯讀) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
員工編號
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={employee.employee_id}
|
||||
disabled
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 帳號基底 (唯讀) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
帳號基底
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={employee.username_base}
|
||||
disabled
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-gray-500 font-mono"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
帳號基底建立後無法修改
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 中文姓名 */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="legal_name"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
中文姓名 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="legal_name"
|
||||
name="legal_name"
|
||||
required
|
||||
value={formData.legal_name}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 英文姓名 */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="english_name"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
英文姓名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="english_name"
|
||||
name="english_name"
|
||||
value={formData.english_name}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 電話 */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="phone"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
辦公室電話
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 手機 */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="mobile"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
手機
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="mobile"
|
||||
name="mobile"
|
||||
value={formData.mobile}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 到職日 */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="hire_date"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
到職日 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="hire_date"
|
||||
name="hire_date"
|
||||
required
|
||||
value={formData.hire_date}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 狀態 */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="status"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
狀態 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
required
|
||||
value={formData.status}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="active">在職</option>
|
||||
<option value="on_leave">留職停薪</option>
|
||||
<option value="terminated">離職</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 按鈕區 */}
|
||||
<div className="mt-8 flex justify-end gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
disabled={saving}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? '儲存中...' : '儲存變更'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
frontend/app/employees/[id]/identities/page.tsx
Normal file
31
frontend/app/employees/[id]/identities/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
|
||||
/**
|
||||
* [已廢棄] 身份管理頁面
|
||||
* 員工身份 (employee_identities) 已廢棄,請使用部門成員管理
|
||||
* 自動重導向至員工詳情頁的「部門成員」Tab
|
||||
*/
|
||||
export default function IdentitiesRedirectPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const employeeId = params.id as string
|
||||
|
||||
useEffect(() => {
|
||||
// 自動重導向到員工詳情頁 (部門成員 Tab 在該頁面)
|
||||
router.replace(`/employees/${employeeId}`)
|
||||
}, [employeeId, router])
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-600">正在重導向至員工詳情頁...</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
身份管理已整合至員工詳情頁的「部門成員」Tab
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
644
frontend/app/employees/[id]/page.tsx
Normal file
644
frontend/app/employees/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
frontend/app/employees/layout.tsx
Normal file
53
frontend/app/employees/layout.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Employees 佈局
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect } from 'react'
|
||||
import { Sidebar } from '@/components/layout/sidebar'
|
||||
import { Breadcrumb } from '@/components/layout/breadcrumb'
|
||||
|
||||
export default function EmployeesLayout({ children }: { children: React.ReactNode }) {
|
||||
const { data: session, status } = useSession()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'unauthenticated') {
|
||||
router.push('/auth/signin')
|
||||
}
|
||||
}, [status, router])
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-indigo-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">載入中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 flex-shrink-0">
|
||||
<Sidebar />
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="p-8">
|
||||
<Breadcrumb />
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
577
frontend/app/employees/new/page.tsx
Normal file
577
frontend/app/employees/new/page.tsx
Normal file
@@ -0,0 +1,577 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { apiClient } from '@/lib/api-client'
|
||||
|
||||
// 第一層部門 (depth=0,擁有 email_domain)
|
||||
interface TopLevelDepartment {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
email_domain?: string
|
||||
effective_email_domain?: string
|
||||
depth: number
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
// 子部門 (depth>=1)
|
||||
interface SubDepartment {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
parent_id: number
|
||||
depth: number
|
||||
effective_email_domain?: string
|
||||
}
|
||||
|
||||
interface EmployeeFormData {
|
||||
username_base: string
|
||||
legal_name: string
|
||||
english_name: string
|
||||
phone: string
|
||||
mobile: string
|
||||
hire_date: string
|
||||
// 組織與職務資訊 (新多層部門架構)
|
||||
top_department_id: string // 第一層部門 (決定郵件網域)
|
||||
department_id: string // 指定部門 (選填,可為任何層)
|
||||
job_title: string
|
||||
email_quota_mb: string
|
||||
// 到職自動化
|
||||
auto_onboard: boolean
|
||||
create_keycloak: boolean
|
||||
create_email: boolean
|
||||
create_drive: boolean
|
||||
}
|
||||
|
||||
interface OnboardResult {
|
||||
created?: boolean
|
||||
disabled?: boolean
|
||||
error?: string | null
|
||||
message?: string
|
||||
username?: string
|
||||
email?: string
|
||||
user_id?: string
|
||||
quota_gb?: number
|
||||
}
|
||||
|
||||
export default function NewEmployeePage() {
|
||||
const { data: session, status } = useSession()
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [topDepartments, setTopDepartments] = useState<TopLevelDepartment[]>([])
|
||||
const [subDepartments, setSubDepartments] = useState<SubDepartment[]>([])
|
||||
const [loadingSubDepts, setLoadingSubDepts] = useState(false)
|
||||
const [onboardResults, setOnboardResults] = useState<{
|
||||
keycloak: OnboardResult
|
||||
email: OnboardResult
|
||||
drive: OnboardResult
|
||||
} | null>(null)
|
||||
const [formData, setFormData] = useState<EmployeeFormData>({
|
||||
username_base: '',
|
||||
legal_name: '',
|
||||
english_name: '',
|
||||
phone: '',
|
||||
mobile: '',
|
||||
hire_date: new Date().toISOString().split('T')[0],
|
||||
top_department_id: '',
|
||||
department_id: '',
|
||||
job_title: '',
|
||||
email_quota_mb: '5120',
|
||||
auto_onboard: true,
|
||||
create_keycloak: true,
|
||||
create_email: true,
|
||||
create_drive: true,
|
||||
})
|
||||
|
||||
// 載入第一層部門列表
|
||||
useEffect(() => {
|
||||
if (status === 'authenticated') {
|
||||
fetchTopDepartments()
|
||||
}
|
||||
}, [status])
|
||||
|
||||
// 當選擇第一層部門時,載入其子部門
|
||||
useEffect(() => {
|
||||
if (formData.top_department_id) {
|
||||
fetchSubDepartments(parseInt(formData.top_department_id))
|
||||
} else {
|
||||
setSubDepartments([])
|
||||
setFormData((prev) => ({ ...prev, department_id: '' }))
|
||||
}
|
||||
}, [formData.top_department_id])
|
||||
|
||||
const fetchTopDepartments = async () => {
|
||||
try {
|
||||
const data = await apiClient.get<TopLevelDepartment[]>('/departments/?depth=0')
|
||||
setTopDepartments(data)
|
||||
} catch (err) {
|
||||
console.error('載入部門失敗:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSubDepartments = async (parentId: number) => {
|
||||
try {
|
||||
setLoadingSubDepts(true)
|
||||
const data = await apiClient.get<SubDepartment[]>(`/departments/?parent_id=${parentId}`)
|
||||
setSubDepartments(data)
|
||||
} catch (err) {
|
||||
console.error('載入子部門失敗:', err)
|
||||
setSubDepartments([])
|
||||
} finally {
|
||||
setLoadingSubDepts(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-lg">載入中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
router.push('/auth/signin')
|
||||
return null
|
||||
}
|
||||
|
||||
const selectedTopDept = topDepartments.find(
|
||||
(d) => d.id === parseInt(formData.top_department_id)
|
||||
)
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value, type } = e.target
|
||||
const checked = (e.target as HTMLInputElement).checked
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// 決定 department_id:若有選子部門用子部門,否則用第一層部門
|
||||
const finalDepartmentId = formData.department_id
|
||||
? parseInt(formData.department_id)
|
||||
: formData.top_department_id
|
||||
? parseInt(formData.top_department_id)
|
||||
: undefined
|
||||
|
||||
const employeePayload = {
|
||||
username_base: formData.username_base,
|
||||
legal_name: formData.legal_name,
|
||||
english_name: formData.english_name || undefined,
|
||||
phone: formData.phone || undefined,
|
||||
mobile: formData.mobile || undefined,
|
||||
hire_date: formData.hire_date,
|
||||
// 新架構:department_id 指向任何層部門
|
||||
department_id: finalDepartmentId,
|
||||
job_title: formData.job_title,
|
||||
email_quota_mb: parseInt(formData.email_quota_mb),
|
||||
}
|
||||
|
||||
const newEmployee = await apiClient.post('/employees/', employeePayload) as any
|
||||
const newEmployeeId = newEmployee.id
|
||||
|
||||
// 自動執行到職流程
|
||||
if (formData.auto_onboard) {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
create_keycloak: String(formData.create_keycloak),
|
||||
create_email: String(formData.create_email),
|
||||
create_drive: String(formData.create_drive),
|
||||
})
|
||||
const onboardResponse = await apiClient.post(
|
||||
`/employees/${newEmployeeId}/onboard?${params.toString()}`,
|
||||
{}
|
||||
) as any
|
||||
setOnboardResults(onboardResponse.results)
|
||||
setTimeout(() => {
|
||||
router.push(`/employees/${newEmployeeId}`)
|
||||
}, 3000)
|
||||
} catch {
|
||||
router.push(`/employees/${newEmployeeId}`)
|
||||
}
|
||||
} else {
|
||||
router.push(`/employees/${newEmployeeId}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = err?.response?.data?.detail || err.message || '新增失敗'
|
||||
setError(errorMessage)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-2xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">新增員工</h1>
|
||||
<p className="mt-2 text-gray-600">填寫基本資料以建立新員工</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 到職流程結果顯示 */}
|
||||
{onboardResults && (
|
||||
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-green-900 mb-3">
|
||||
✓ 員工已建立,到職流程執行完成
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{formData.create_keycloak && (
|
||||
<div className={`flex items-center gap-2 text-sm ${onboardResults.keycloak?.created ? 'text-green-700' : 'text-amber-700'}`}>
|
||||
<span>{onboardResults.keycloak?.created ? '✓' : '⚠'}</span>
|
||||
<span>Keycloak SSO: {onboardResults.keycloak?.message || (onboardResults.keycloak?.error ? `失敗 - ${onboardResults.keycloak.error}` : '未執行')}</span>
|
||||
</div>
|
||||
)}
|
||||
{formData.create_email && (
|
||||
<div className={`flex items-center gap-2 text-sm ${onboardResults.email?.created ? 'text-green-700' : 'text-amber-700'}`}>
|
||||
<span>{onboardResults.email?.created ? '✓' : '⚠'}</span>
|
||||
<span>郵件帳號: {onboardResults.email?.message || (onboardResults.email?.error ? `失敗 - ${onboardResults.email.error}` : '未執行')}</span>
|
||||
</div>
|
||||
)}
|
||||
{formData.create_drive && (
|
||||
<div className={`flex items-center gap-2 text-sm ${onboardResults.drive?.created ? 'text-green-700' : 'text-amber-700'}`}>
|
||||
<span>{onboardResults.drive?.created ? '✓' : '⚠'}</span>
|
||||
<span>雲端硬碟: {onboardResults.drive?.message || (onboardResults.drive?.error ? `失敗 - ${onboardResults.drive.error}` : '未執行')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-green-600">3 秒後自動跳轉至員工詳情頁...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-white shadow rounded-lg p-6">
|
||||
<div className="space-y-6">
|
||||
{/* 單一登入帳號 */}
|
||||
<div>
|
||||
<label htmlFor="username_base" className="block text-sm font-medium text-gray-700">
|
||||
單一登入帳號 (SSO) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
員工的 SSO 登入帳號名稱,系統根據所屬第一層部門自動加上網域
|
||||
<br />
|
||||
例如: 輸入 <code className="bg-gray-100 px-1 rounded">porsche.chen</code> →
|
||||
完整帳號 <code className="bg-gray-100 px-1 rounded">porsche.chen@ease.taipei</code>
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
id="username_base"
|
||||
name="username_base"
|
||||
required
|
||||
value={formData.username_base}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
|
||||
placeholder="firstname.lastname"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 中文姓名 */}
|
||||
<div>
|
||||
<label htmlFor="legal_name" className="block text-sm font-medium text-gray-700">
|
||||
中文姓名 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="legal_name"
|
||||
name="legal_name"
|
||||
required
|
||||
value={formData.legal_name}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
|
||||
placeholder="陳保時"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 英文姓名 */}
|
||||
<div>
|
||||
<label htmlFor="english_name" className="block text-sm font-medium text-gray-700">
|
||||
英文姓名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="english_name"
|
||||
name="english_name"
|
||||
value={formData.english_name}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
|
||||
placeholder="Porsche Chen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 電話 */}
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">
|
||||
辦公室電話
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
|
||||
placeholder="02-12345678"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 手機 */}
|
||||
<div>
|
||||
<label htmlFor="mobile" className="block text-sm font-medium text-gray-700">
|
||||
手機
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="mobile"
|
||||
name="mobile"
|
||||
value={formData.mobile}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
|
||||
placeholder="0912-345678"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 到職日 */}
|
||||
<div>
|
||||
<label htmlFor="hire_date" className="block text-sm font-medium text-gray-700">
|
||||
到職日 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="hire_date"
|
||||
name="hire_date"
|
||||
required
|
||||
value={formData.hire_date}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分隔線 */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
組織與職務資訊
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 第一層部門 (原事業部) */}
|
||||
<div>
|
||||
<label htmlFor="top_department_id" className="block text-sm font-medium text-gray-700">
|
||||
所屬部門 (第一層) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="top_department_id"
|
||||
name="top_department_id"
|
||||
required
|
||||
value={formData.top_department_id}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
|
||||
>
|
||||
<option value="">請選擇所屬部門</option>
|
||||
{topDepartments.map((dept) => (
|
||||
<option key={dept.id} value={dept.id}>
|
||||
{dept.name}
|
||||
{dept.email_domain ? ` (@${dept.email_domain})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
決定員工的主要郵件網域
|
||||
{selectedTopDept?.email_domain && (
|
||||
<span className="ml-1 font-mono text-blue-600">
|
||||
@{selectedTopDept.email_domain}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 子部門 (選填) */}
|
||||
<div>
|
||||
<label htmlFor="department_id" className="block text-sm font-medium text-gray-700">
|
||||
指定部門 (選填)
|
||||
</label>
|
||||
<select
|
||||
id="department_id"
|
||||
name="department_id"
|
||||
value={formData.department_id}
|
||||
onChange={handleChange}
|
||||
disabled={!formData.top_department_id || loadingSubDepts}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900 disabled:bg-gray-100 disabled:cursor-not-allowed disabled:text-gray-500"
|
||||
>
|
||||
<option value="">
|
||||
{!formData.top_department_id
|
||||
? '請先選擇第一層部門'
|
||||
: loadingSubDepts
|
||||
? '載入中...'
|
||||
: subDepartments.length > 0
|
||||
? '請選擇子部門 (選填)'
|
||||
: '此部門尚無子部門'}
|
||||
</option>
|
||||
{subDepartments.map((dept) => (
|
||||
<option key={dept.id} value={dept.id}>
|
||||
{dept.name} ({dept.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
選填,可稍後在部門成員管理中設定
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 職稱 */}
|
||||
<div>
|
||||
<label htmlFor="job_title" className="block text-sm font-medium text-gray-700">
|
||||
職稱 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="job_title"
|
||||
name="job_title"
|
||||
required
|
||||
value={formData.job_title}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
|
||||
placeholder="例如: 軟體工程師、技術總監"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 郵件配額 */}
|
||||
<div>
|
||||
<label htmlFor="email_quota_mb" className="block text-sm font-medium text-gray-700">
|
||||
郵件配額 (MB) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="email_quota_mb"
|
||||
name="email_quota_mb"
|
||||
required
|
||||
min="1024"
|
||||
max="51200"
|
||||
step="1024"
|
||||
value={formData.email_quota_mb}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
預設 5120 MB (5 GB),可設定 1024 MB 至 51200 MB 之間
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 到職自動化流程 */}
|
||||
<div className="border-t border-gray-200 pt-6 mt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
自動執行到職流程
|
||||
</h3>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="auto_onboard"
|
||||
checked={formData.auto_onboard}
|
||||
onChange={handleChange}
|
||||
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">建立員工後立即執行</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{formData.auto_onboard && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-3">
|
||||
<p className="text-sm text-blue-700 font-medium">選擇要自動建立的帳號:</p>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="create_keycloak"
|
||||
checked={formData.create_keycloak}
|
||||
onChange={handleChange}
|
||||
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700">Keycloak SSO 帳號</span>
|
||||
<p className="text-xs text-gray-500">建立 SSO 登入帳號並設定初始密碼 (首次登入需修改)</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="create_email"
|
||||
checked={formData.create_email}
|
||||
onChange={handleChange}
|
||||
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700">郵件帳號</span>
|
||||
<p className="text-xs text-gray-500">建立主要郵件帳號</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="create_drive"
|
||||
checked={formData.create_drive}
|
||||
onChange={handleChange}
|
||||
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700">雲端硬碟帳號</span>
|
||||
<p className="text-xs text-gray-500">建立 Nextcloud 帳號 (Drive Service 未上線時自動跳過)</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 按鈕區 */}
|
||||
<div className="mt-8 flex justify-end gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
disabled={loading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? '建立中...' : '建立員工'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* 提示訊息 */}
|
||||
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-blue-900 mb-2">
|
||||
💡 自動化流程說明
|
||||
</h3>
|
||||
<ul className="text-sm text-blue-700 space-y-1">
|
||||
<li>• 員工編號將自動產生 (EMP001, EMP002...)</li>
|
||||
<li>• 目前狀態會設定為「在職」</li>
|
||||
<li>• SSO 帳號將根據所屬第一層部門的網域自動生成</li>
|
||||
<li>• 建立員工後可在「組織架構」頁面管理部門成員</li>
|
||||
<li>• 勾選「自動執行到職流程」可立即建立:</li>
|
||||
<li className="ml-4">- Keycloak SSO 帳號 (含初始密碼)</li>
|
||||
<li className="ml-4">- 主要郵件帳號</li>
|
||||
<li className="ml-4">- 雲端硬碟帳號 (Drive Service)</li>
|
||||
<li>• 也可在建立員工後,從員工詳情頁手動觸發到職流程</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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