Files
hr-portal/frontend/app/departments/page.tsx
Porsche Chen 360533393f feat: HR Portal - Complete Multi-Tenant System with Redis Session Storage
Major Features:
-  Multi-tenant architecture (tenant isolation)
-  Employee CRUD with lifecycle management (onboarding/offboarding)
-  Department tree structure with email domain management
-  Company info management (single-record editing)
-  System functions CRUD (permission management)
-  Email account management (multi-account per employee)
-  Keycloak SSO integration (auth.lab.taipei)
-  Redis session storage (10.1.0.254:6379)
  - Solves Cookie 4KB limitation
  - Cross-system session sharing
  - Sliding expiration (8 hours)
  - Automatic token refresh

Technical Stack:
Backend:
- FastAPI + SQLAlchemy
- PostgreSQL 16 (10.1.0.20:5433)
- Keycloak Admin API integration
- Docker Mailserver integration (SSH)
- Alembic migrations

Frontend:
- Next.js 14 (App Router)
- NextAuth 4 with Keycloak Provider
- Redis session storage (ioredis)
- Tailwind CSS

Infrastructure:
- Redis 7 (10.1.0.254:6379) - Session + Cache
- Keycloak 26.1.0 (auth.lab.taipei)
- Docker Mailserver (10.1.0.254)

Architecture Highlights:
- Session管理由 Keycloak + Redis 統一控制
- 支援多系統 (HR/WebMail/Calendar/Drive/Office) 共享 session
- Token 自動刷新,異質服務整合
- 未來可無縫遷移到雲端

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-23 20:12:43 +08:00

445 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { 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>
)
}