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:
351
frontend/app/departments/DepartmentFormModal.tsx
Normal file
351
frontend/app/departments/DepartmentFormModal.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import apiClient from '@/lib/api-client'
|
||||
import AlertDialog from '@/components/ui/AlertDialog'
|
||||
|
||||
interface Department {
|
||||
id?: number
|
||||
parent_id: number | null
|
||||
code: string
|
||||
name: string
|
||||
name_en: string | null
|
||||
email_domain: string | null
|
||||
email_address: string | null
|
||||
email_quota_mb: number
|
||||
description: string | null
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
interface ParentDepartment {
|
||||
id: number
|
||||
name: string
|
||||
depth: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
department: Department | null
|
||||
parentDepartments: ParentDepartment[]
|
||||
onClose: () => void
|
||||
onSave: (isEdit: boolean) => void
|
||||
}
|
||||
|
||||
export default function DepartmentFormModal({ department: editingDepartment, parentDepartments, onClose, onSave }: Props) {
|
||||
const [formData, setFormData] = useState<Department>({
|
||||
parent_id: null,
|
||||
code: '',
|
||||
name: '',
|
||||
name_en: null,
|
||||
email_domain: null,
|
||||
email_address: null,
|
||||
email_quota_mb: 5120,
|
||||
description: null,
|
||||
is_active: true,
|
||||
})
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [isTopLevel, setIsTopLevel] = useState(true)
|
||||
|
||||
// 對話框狀態
|
||||
const [alertDialog, setAlertDialog] = useState<{
|
||||
isOpen: boolean
|
||||
title: string
|
||||
message: string
|
||||
type: 'info' | 'warning' | 'error' | 'success'
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'info',
|
||||
})
|
||||
|
||||
// 如果是編輯模式,填入現有資料
|
||||
useEffect(() => {
|
||||
if (editingDepartment) {
|
||||
setFormData(editingDepartment)
|
||||
setIsTopLevel(editingDepartment.parent_id === null)
|
||||
}
|
||||
}, [editingDepartment])
|
||||
|
||||
const handleChange = (field: keyof Department, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleParentChange = (value: string) => {
|
||||
const parentId = value === '' ? null : parseInt(value)
|
||||
setFormData(prev => ({ ...prev, parent_id: parentId }))
|
||||
setIsTopLevel(parentId === null)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
// 驗證
|
||||
if (!formData.code || !formData.name) {
|
||||
setAlertDialog({
|
||||
isOpen: true,
|
||||
title: '欄位驗證',
|
||||
message: '請填寫部門代碼和名稱',
|
||||
type: 'warning',
|
||||
})
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 第一層部門必須填寫郵件網域
|
||||
if (isTopLevel && !formData.email_domain) {
|
||||
setAlertDialog({
|
||||
isOpen: true,
|
||||
title: '欄位驗證',
|
||||
message: '第一層部門必須填寫郵件網域',
|
||||
type: 'warning',
|
||||
})
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 準備資料
|
||||
const submitData: any = {
|
||||
parent_id: formData.parent_id,
|
||||
code: formData.code,
|
||||
name: formData.name,
|
||||
name_en: formData.name_en,
|
||||
email_address: formData.email_address,
|
||||
email_quota_mb: formData.email_quota_mb,
|
||||
description: formData.description,
|
||||
is_active: formData.is_active,
|
||||
}
|
||||
|
||||
// 只有第一層部門才送 email_domain
|
||||
if (isTopLevel) {
|
||||
submitData.email_domain = formData.email_domain
|
||||
}
|
||||
|
||||
const isEdit = !!editingDepartment
|
||||
|
||||
if (isEdit) {
|
||||
// 更新
|
||||
await apiClient.put(`/departments/${editingDepartment.id}`, submitData)
|
||||
} else {
|
||||
// 新增
|
||||
await apiClient.post('/departments', submitData)
|
||||
}
|
||||
|
||||
onSave(isEdit)
|
||||
onClose()
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save department:', error)
|
||||
setAlertDialog({
|
||||
isOpen: true,
|
||||
title: '儲存失敗',
|
||||
message: error.response?.data?.detail || '儲存失敗,請稍後再試',
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 animate-fadeIn"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-2xl w-full max-w-5xl max-h-[90vh] overflow-hidden animate-slideIn"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Card Header */}
|
||||
<div className="bg-gradient-to-r from-blue-600 to-blue-700 px-5 py-3.5 flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{formData.name || '部門資料'} - {editingDepartment ? '編輯作業' : '新增作業'}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-white hover:text-blue-100 transition-colors"
|
||||
aria-label="關閉"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Card Body - Scrollable */}
|
||||
<div className="overflow-y-auto max-h-[calc(90vh-180px)]">
|
||||
<form onSubmit={handleSubmit} className="p-6">
|
||||
{/* Row 1: 上層部門 + 部門代碼 */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
上層部門
|
||||
</label>
|
||||
<select
|
||||
value={formData.parent_id === null ? '' : formData.parent_id}
|
||||
onChange={(e) => handleParentChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
|
||||
>
|
||||
<option value="">第一層 (無上層)</option>
|
||||
{parentDepartments.map(dept => (
|
||||
<option key={dept.id} value={dept.id}>
|
||||
{dept.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
部門代碼 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.code}
|
||||
onChange={(e) => handleChange('code', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
|
||||
placeholder="例如: BD"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: 部門名稱 + 英文名稱 */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
部門名稱 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
|
||||
placeholder="例如: 業務發展部"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
英文名稱
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name_en || ''}
|
||||
onChange={(e) => handleChange('name_en', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
|
||||
placeholder="例如: Business Development"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: 郵件網域 (只在第一層顯示) */}
|
||||
{isTopLevel && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
郵件網域 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.email_domain || ''}
|
||||
onChange={(e) => handleChange('email_domain', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
|
||||
placeholder="例如: ease.taipei"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 4: 部門信箱 + 信箱配額 */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
部門信箱
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email_address || ''}
|
||||
onChange={(e) => handleChange('email_address', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
|
||||
placeholder="例如: bd@ease.taipei"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
信箱配額 (MB)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.email_quota_mb}
|
||||
onChange={(e) => handleChange('email_quota_mb', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 說明 */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
說明
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description || ''}
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
|
||||
rows={2}
|
||||
placeholder="部門說明..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 啟用 */}
|
||||
<div>
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => handleChange('is_active', e.target.checked)}
|
||||
className="mr-2 w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">啟用</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Card Footer */}
|
||||
<div className="border-t bg-gray-50 px-5 py-2.5 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors text-sm font-medium"
|
||||
disabled={loading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
onClick={handleSubmit}
|
||||
className="px-4 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 text-sm font-medium"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '儲存中...' : '儲存'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Alert Dialog */}
|
||||
<AlertDialog
|
||||
isOpen={alertDialog.isOpen}
|
||||
title={alertDialog.title}
|
||||
message={alertDialog.message}
|
||||
type={alertDialog.type}
|
||||
onConfirm={() => setAlertDialog({ ...alertDialog, isOpen: false })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
53
frontend/app/departments/layout.tsx
Normal file
53
frontend/app/departments/layout.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Departments 佈局
|
||||
*/
|
||||
'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 DepartmentsLayout({ 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>
|
||||
)
|
||||
}
|
||||
444
frontend/app/departments/page.tsx
Normal file
444
frontend/app/departments/page.tsx
Normal file
@@ -0,0 +1,444 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import apiClient from '@/lib/api-client'
|
||||
import AlertDialog from '@/components/ui/AlertDialog'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog'
|
||||
import DepartmentFormModal from './DepartmentFormModal'
|
||||
|
||||
interface Department {
|
||||
id: number
|
||||
tenant_id: number
|
||||
parent_id: number | null
|
||||
code: string
|
||||
name: string
|
||||
name_en: string | null
|
||||
depth: number
|
||||
email_domain: string | null
|
||||
effective_email_domain: string | null
|
||||
email_address: string | null
|
||||
email_quota_mb: number
|
||||
description: string | null
|
||||
is_active: boolean
|
||||
member_count: number
|
||||
}
|
||||
|
||||
interface ParentDepartment {
|
||||
id: number
|
||||
name: string
|
||||
depth: number
|
||||
}
|
||||
|
||||
export default function DepartmentsPage() {
|
||||
const [departments, setDepartments] = useState<Department[]>([])
|
||||
const [parentDepartments, setParentDepartments] = useState<ParentDepartment[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingDepartment, setEditingDepartment] = useState<Department | null>(null)
|
||||
|
||||
// 篩選條件
|
||||
const [filterDepth, setFilterDepth] = useState<string>('all')
|
||||
const [filterActive, setFilterActive] = useState<string>('all')
|
||||
const [filterParent, setFilterParent] = useState<string>('all')
|
||||
|
||||
// 分頁
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(5)
|
||||
|
||||
// 對話框
|
||||
const [alertDialog, setAlertDialog] = useState<{
|
||||
isOpen: boolean
|
||||
title: string
|
||||
message: string
|
||||
type: 'info' | 'warning' | 'error' | 'success'
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'info',
|
||||
})
|
||||
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
isOpen: boolean
|
||||
title: string
|
||||
message: string
|
||||
onConfirm: () => void
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
onConfirm: () => {},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadDepartments()
|
||||
loadParentDepartments()
|
||||
}, [])
|
||||
|
||||
const loadDepartments = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response: any = await apiClient.get('/departments?include_inactive=true')
|
||||
setDepartments(response || [])
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load departments:', error)
|
||||
setAlertDialog({
|
||||
isOpen: true,
|
||||
title: '載入失敗',
|
||||
message: error.response?.data?.detail || '無法載入部門資料',
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadParentDepartments = async () => {
|
||||
try {
|
||||
// 載入所有部門作為上層部門選項
|
||||
const response: any = await apiClient.get('/departments?include_inactive=false')
|
||||
setParentDepartments(response || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load parent departments:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingDepartment(null)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleEdit = (department: Department) => {
|
||||
setEditingDepartment(department)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleDelete = (department: Department) => {
|
||||
setConfirmDialog({
|
||||
isOpen: true,
|
||||
title: '確認刪除',
|
||||
message: `確定要刪除部門「${department.name}」嗎?此操作無法復原。`,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await apiClient.delete(`/departments/${department.id}`)
|
||||
loadDepartments()
|
||||
setAlertDialog({
|
||||
isOpen: true,
|
||||
title: '刪除成功',
|
||||
message: '部門已成功刪除',
|
||||
type: 'success',
|
||||
})
|
||||
} catch (error: any) {
|
||||
setAlertDialog({
|
||||
isOpen: true,
|
||||
title: '刪除失敗',
|
||||
message: error.response?.data?.detail || '刪除失敗,請稍後再試',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
setConfirmDialog({ ...confirmDialog, isOpen: false })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggleActive = async (department: Department) => {
|
||||
try {
|
||||
await apiClient.patch(`/departments/${department.id}`, {
|
||||
is_active: !department.is_active,
|
||||
})
|
||||
loadDepartments()
|
||||
setAlertDialog({
|
||||
isOpen: true,
|
||||
title: '切換成功',
|
||||
message: `部門已${!department.is_active ? '啟用' : '停用'}`,
|
||||
type: 'success',
|
||||
})
|
||||
} catch (error: any) {
|
||||
setAlertDialog({
|
||||
isOpen: true,
|
||||
title: '切換失敗',
|
||||
message: error.response?.data?.detail || '切換狀態失敗',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 篩選邏輯
|
||||
const filteredDepartments = departments.filter(dept => {
|
||||
if (filterDepth !== 'all' && dept.depth !== parseInt(filterDepth)) return false
|
||||
if (filterActive === 'active' && !dept.is_active) return false
|
||||
if (filterActive === 'inactive' && dept.is_active) return false
|
||||
if (filterParent === 'top' && dept.parent_id !== null) return false
|
||||
if (filterParent !== 'all' && filterParent !== 'top' && dept.parent_id !== parseInt(filterParent)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
// 分頁邏輯
|
||||
const totalPages = Math.ceil(filteredDepartments.length / pageSize)
|
||||
const startIndex = (currentPage - 1) * pageSize
|
||||
const paginatedDepartments = filteredDepartments.slice(startIndex, startIndex + pageSize)
|
||||
|
||||
const getParentName = (parentId: number | null) => {
|
||||
if (!parentId) return '-'
|
||||
const parent = departments.find(d => d.id === parentId)
|
||||
return parent ? parent.name : '-'
|
||||
}
|
||||
|
||||
const getDepthLabel = (depth: number) => {
|
||||
return `第${depth + 1}層`
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">部門資料維護</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">管理組織架構與部門資料</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
+ 新增部門
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200 mb-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">層級</label>
|
||||
<select
|
||||
value={filterDepth}
|
||||
onChange={(e) => { setFilterDepth(e.target.value); setCurrentPage(1); }}
|
||||
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
<option value="0">第1層</option>
|
||||
<option value="1">第2層</option>
|
||||
<option value="2">第3層</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">啟用狀態</label>
|
||||
<select
|
||||
value={filterActive}
|
||||
onChange={(e) => { setFilterActive(e.target.value); setCurrentPage(1); }}
|
||||
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
<option value="active">啟用</option>
|
||||
<option value="inactive">停用</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">上層部門</label>
|
||||
<select
|
||||
value={filterParent}
|
||||
onChange={(e) => { setFilterParent(e.target.value); setCurrentPage(1); }}
|
||||
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
<option value="top">第一層</option>
|
||||
{parentDepartments.map(dept => (
|
||||
<option key={dept.id} value={dept.id}>{dept.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-700">每頁顯示</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPageSize(parseInt(e.target.value)); setCurrentPage(1); }}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="5">5</option>
|
||||
<option value="10">10</option>
|
||||
<option value="15">15</option>
|
||||
<option value="20">20</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
</select>
|
||||
<span className="text-sm text-gray-700">筆</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-700">
|
||||
共 {filteredDepartments.length} 筆資料
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DataTable */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-blue-900">
|
||||
<tr>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-white uppercase tracking-wider">ID</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-white uppercase tracking-wider">層級</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-white uppercase tracking-wider">部門代碼</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-white uppercase tracking-wider">部門名稱</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-white uppercase tracking-wider">上層部門</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-white uppercase tracking-wider">郵件網域</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-white uppercase tracking-wider">成員數</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-white uppercase tracking-wider">啟用</th>
|
||||
<th className="px-4 py-2.5 text-center text-xs font-semibold text-white uppercase tracking-wider">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-4 py-8 text-center text-sm text-gray-500">
|
||||
載入中...
|
||||
</td>
|
||||
</tr>
|
||||
) : paginatedDepartments.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-4 py-8 text-center text-sm text-gray-500">
|
||||
尚無部門資料
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedDepartments.map((dept) => (
|
||||
<tr key={dept.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-2.5 text-sm text-gray-900">{dept.id}</td>
|
||||
<td className="px-4 py-2.5 text-sm text-gray-900">{getDepthLabel(dept.depth)}</td>
|
||||
<td className="px-4 py-2.5 text-sm text-gray-900 font-medium">{dept.code}</td>
|
||||
<td className="px-4 py-2.5 text-sm text-gray-900">{dept.name}</td>
|
||||
<td className="px-4 py-2.5 text-sm text-gray-600">{getParentName(dept.parent_id)}</td>
|
||||
<td className="px-4 py-2.5 text-sm text-gray-600">{dept.effective_email_domain || '-'}</td>
|
||||
<td className="px-4 py-2.5 text-sm text-gray-900">{dept.member_count || 0}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<button
|
||||
onClick={() => handleToggleActive(dept)}
|
||||
className={`px-2 py-1 rounded text-xs font-medium cursor-pointer ${
|
||||
dept.is_active
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||
: 'bg-red-100 text-red-800 hover:bg-red-200'
|
||||
}`}
|
||||
>
|
||||
{dept.is_active ? '是' : '否'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-center">
|
||||
<div className="flex justify-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(dept)}
|
||||
className="px-3 py-1 text-xs font-medium text-blue-600 hover:text-blue-800 border border-blue-600 hover:border-blue-800 rounded transition-colors"
|
||||
>
|
||||
編輯
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(dept)}
|
||||
className="px-3 py-1 text-xs font-medium text-red-600 hover:text-red-800 border border-red-600 hover:border-red-800 rounded transition-colors"
|
||||
>
|
||||
刪除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="bg-gray-50 px-4 py-3 flex items-center justify-between border-t border-gray-200">
|
||||
<div className="text-sm text-gray-700">
|
||||
顯示第 {startIndex + 1} - {Math.min(startIndex + pageSize, filteredDepartments.length)} 筆,共 {filteredDepartments.length} 筆
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
{[...Array(totalPages)].map((_, i) => (
|
||||
<button
|
||||
key={i + 1}
|
||||
onClick={() => setCurrentPage(i + 1)}
|
||||
className={`px-3 py-1 text-sm border rounded ${
|
||||
currentPage === i + 1
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'border-gray-300 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<DepartmentFormModal
|
||||
department={editingDepartment}
|
||||
parentDepartments={parentDepartments}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSave={(isEdit: boolean) => {
|
||||
setShowModal(false)
|
||||
loadDepartments()
|
||||
loadParentDepartments()
|
||||
setAlertDialog({
|
||||
isOpen: true,
|
||||
title: '儲存成功',
|
||||
message: `部門已成功${isEdit ? '更新' : '新增'}`,
|
||||
type: 'success',
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Alert Dialog */}
|
||||
<AlertDialog
|
||||
isOpen={alertDialog.isOpen}
|
||||
title={alertDialog.title}
|
||||
message={alertDialog.message}
|
||||
type={alertDialog.type}
|
||||
onConfirm={() => setAlertDialog({ ...alertDialog, isOpen: false })}
|
||||
/>
|
||||
|
||||
{/* Confirm Dialog */}
|
||||
<ConfirmDialog
|
||||
isOpen={confirmDialog.isOpen}
|
||||
title={confirmDialog.title}
|
||||
message={confirmDialog.message}
|
||||
onConfirm={confirmDialog.onConfirm}
|
||||
onCancel={() => setConfirmDialog({ ...confirmDialog, isOpen: false })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user