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>
429 lines
16 KiB
TypeScript
429 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import apiClient from '@/lib/api-client'
|
|
import AlertDialog from '@/components/ui/AlertDialog'
|
|
|
|
interface SystemFunction {
|
|
id?: number
|
|
code: string
|
|
name: string
|
|
function_type: number
|
|
upper_function_id: number
|
|
order: number
|
|
function_icon: string
|
|
module_code: string | null
|
|
module_functions: string[]
|
|
description: string
|
|
is_mana: boolean
|
|
is_active: boolean
|
|
edit_by: number
|
|
}
|
|
|
|
interface Props {
|
|
function: SystemFunction | null
|
|
onClose: () => void
|
|
onSave: (isEdit: boolean) => void
|
|
}
|
|
|
|
const AVAILABLE_OPERATIONS = [
|
|
{ value: 'View', label: 'View' },
|
|
{ value: 'Create', label: 'Create' },
|
|
{ value: 'Read', label: 'Read' },
|
|
{ value: 'Update', label: 'Update' },
|
|
{ value: 'Delete', label: 'Delete' },
|
|
{ value: 'Print', label: 'Print' },
|
|
{ value: 'File', label: 'File' },
|
|
]
|
|
|
|
export default function FunctionFormModal({ function: editingFunction, onClose, onSave }: Props) {
|
|
const [formData, setFormData] = useState<SystemFunction>({
|
|
code: '',
|
|
name: '',
|
|
function_type: 2,
|
|
upper_function_id: 0,
|
|
order: 10,
|
|
function_icon: '',
|
|
module_code: null,
|
|
module_functions: [],
|
|
description: '',
|
|
is_mana: false,
|
|
is_active: true,
|
|
edit_by: 1,
|
|
})
|
|
|
|
const [parentFunctions, setParentFunctions] = useState<any[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
// 對話框狀態
|
|
const [alertDialog, setAlertDialog] = useState<{
|
|
isOpen: boolean
|
|
title: string
|
|
message: string
|
|
type: 'info' | 'warning' | 'error' | 'success'
|
|
}>({
|
|
isOpen: false,
|
|
title: '',
|
|
message: '',
|
|
type: 'info',
|
|
})
|
|
|
|
// 載入上層功能選項 (只顯示 function_type=1 的 NODE)
|
|
useEffect(() => {
|
|
const loadParentFunctions = async () => {
|
|
try {
|
|
const response: any = await apiClient.get('/system-functions?function_type=1&page_size=100')
|
|
setParentFunctions(response.items || [])
|
|
} catch (error) {
|
|
console.error('Failed to load parent functions:', error)
|
|
}
|
|
}
|
|
loadParentFunctions()
|
|
}, [])
|
|
|
|
// 如果是編輯模式,填入現有資料
|
|
useEffect(() => {
|
|
if (editingFunction) {
|
|
setFormData(editingFunction)
|
|
}
|
|
}, [editingFunction])
|
|
|
|
const handleChange = (field: keyof SystemFunction, value: any) => {
|
|
setFormData(prev => ({ ...prev, [field]: value }))
|
|
}
|
|
|
|
const handleOperationToggle = (operation: string) => {
|
|
const currentOperations = formData.module_functions || []
|
|
const newOperations = currentOperations.includes(operation)
|
|
? currentOperations.filter(op => op !== operation)
|
|
: [...currentOperations, operation]
|
|
|
|
setFormData(prev => ({ ...prev, module_functions: newOperations }))
|
|
}
|
|
|
|
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 (formData.function_type === 2 && !formData.module_code) {
|
|
setAlertDialog({
|
|
isOpen: true,
|
|
title: '欄位驗證',
|
|
message: 'FUNCTION 類型需要填寫模組代碼',
|
|
type: 'warning',
|
|
})
|
|
setLoading(false)
|
|
return
|
|
}
|
|
|
|
// 準備資料
|
|
const submitData = {
|
|
...formData,
|
|
module_code: formData.function_type === 1 ? null : formData.module_code,
|
|
module_functions: formData.function_type === 1 ? [] : formData.module_functions,
|
|
}
|
|
|
|
const isEdit = !!editingFunction
|
|
|
|
if (isEdit) {
|
|
// 更新
|
|
await apiClient.put(`/system-functions/${editingFunction.id}`, submitData)
|
|
} else {
|
|
// 新增
|
|
await apiClient.post('/system-functions', submitData)
|
|
}
|
|
|
|
onSave(isEdit)
|
|
onClose()
|
|
} catch (error: any) {
|
|
console.error('Failed to save function:', 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 || '系統功能'} - {editingFunction ? '編輯作業' : '新增作業'}
|
|
</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">
|
|
功能代碼 <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="例如: dashboard"
|
|
required
|
|
/>
|
|
</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.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>
|
|
|
|
{/* 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>
|
|
<div className="flex gap-3">
|
|
<label className="flex items-center cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
value={1}
|
|
checked={formData.function_type === 1}
|
|
onChange={(e) => handleChange('function_type', parseInt(e.target.value))}
|
|
className="mr-2"
|
|
/>
|
|
<span className="px-2 py-1 bg-purple-100 text-purple-800 rounded text-xs font-medium">NODE</span>
|
|
</label>
|
|
<label className="flex items-center cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
value={2}
|
|
checked={formData.function_type === 2}
|
|
onChange={(e) => handleChange('function_type', parseInt(e.target.value))}
|
|
className="mr-2"
|
|
/>
|
|
<span className="px-2 py-1 bg-green-100 text-green-800 rounded text-xs font-medium">FUNCTION</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
上層功能
|
|
</label>
|
|
<select
|
|
value={formData.upper_function_id}
|
|
onChange={(e) => handleChange('upper_function_id', 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"
|
|
>
|
|
<option value={0}>根層 (無上層)</option>
|
|
{parentFunctions.map(func => (
|
|
<option key={func.id} value={func.id}>
|
|
{func.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 3: 順序 + Emoji 圖示 */}
|
|
<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="number"
|
|
value={formData.order}
|
|
onChange={(e) => handleChange('order', 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={1}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Emoji 圖示
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
value={formData.function_icon}
|
|
onChange={(e) => handleChange('function_icon', e.target.value)}
|
|
className="flex-1 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="📊"
|
|
/>
|
|
{formData.function_icon && (
|
|
<span className="text-2xl">{formData.function_icon}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 4: 模組代碼 (只在 FUNCTION 時顯示) */}
|
|
{formData.function_type === 2 && (
|
|
<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.module_code || ''}
|
|
onChange={(e) => handleChange('module_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="例如: dashboard"
|
|
required
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 模組操作權限 (橫置) */}
|
|
{formData.function_type === 2 && (
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
模組操作權限
|
|
</label>
|
|
<div className="flex flex-wrap gap-3">
|
|
{AVAILABLE_OPERATIONS.map(op => (
|
|
<label
|
|
key={op.value}
|
|
className={`px-3 py-2 rounded-lg border-2 cursor-pointer transition-all text-sm ${
|
|
formData.module_functions.includes(op.value)
|
|
? 'bg-blue-50 border-blue-500 text-blue-700'
|
|
: 'bg-white border-gray-300 text-gray-700 hover:border-blue-300'
|
|
}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.module_functions.includes(op.value)}
|
|
onChange={() => handleOperationToggle(op.value)}
|
|
className="mr-2"
|
|
/>
|
|
{op.label}
|
|
</label>
|
|
))}
|
|
</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>
|
|
|
|
{/* Row 5: 系統管理 + 啟用 */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.is_mana}
|
|
onChange={(e) => handleChange('is_mana', e.target.checked)}
|
|
className="mr-2 w-4 h-4"
|
|
/>
|
|
<span className="text-sm font-medium text-gray-700">系統管理功能</span>
|
|
</label>
|
|
</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>
|
|
</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>
|
|
)
|
|
}
|