Files
hr-portal/frontend/app/departments/DepartmentFormModal.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

352 lines
12 KiB
TypeScript

'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>
)
}