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:
594
frontend/app/organization/page.tsx
Normal file
594
frontend/app/organization/page.tsx
Normal file
@@ -0,0 +1,594 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { apiClient } from '@/lib/api-client'
|
||||
|
||||
interface DepartmentTreeNode {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
name_en?: string
|
||||
depth: number
|
||||
parent_id: number | null
|
||||
email_domain?: string
|
||||
effective_email_domain?: string
|
||||
email_address?: string
|
||||
email_quota_mb: number
|
||||
description?: string
|
||||
is_active: boolean
|
||||
is_top_level: boolean
|
||||
member_count: number
|
||||
children: DepartmentTreeNode[]
|
||||
}
|
||||
|
||||
interface DepartmentFormData {
|
||||
name: string
|
||||
name_en: string
|
||||
code: string
|
||||
description: string
|
||||
email_domain: string
|
||||
email_address: string
|
||||
email_quota_mb: number
|
||||
parent_id: number | null
|
||||
}
|
||||
|
||||
const EMPTY_FORM: DepartmentFormData = {
|
||||
name: '',
|
||||
name_en: '',
|
||||
code: '',
|
||||
description: '',
|
||||
email_domain: '',
|
||||
email_address: '',
|
||||
email_quota_mb: 5120,
|
||||
parent_id: null,
|
||||
}
|
||||
|
||||
// ─── Modal:新增 / 編輯部門 ─────────────────────────────────────────────
|
||||
function DepartmentModal({
|
||||
mode,
|
||||
parentDept,
|
||||
editTarget,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
mode: 'create_top' | 'create_child' | 'edit'
|
||||
parentDept?: DepartmentTreeNode // create_child 時的父部門
|
||||
editTarget?: DepartmentTreeNode // edit 時的目標部門
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [form, setForm] = useState<DepartmentFormData>(() => {
|
||||
if (mode === 'edit' && editTarget) {
|
||||
return {
|
||||
name: editTarget.name,
|
||||
name_en: editTarget.name_en ?? '',
|
||||
code: editTarget.code,
|
||||
description: editTarget.description ?? '',
|
||||
email_domain: editTarget.email_domain ?? '',
|
||||
email_address: editTarget.email_address ?? '',
|
||||
email_quota_mb: editTarget.email_quota_mb,
|
||||
parent_id: editTarget.parent_id,
|
||||
}
|
||||
}
|
||||
if (mode === 'create_child' && parentDept) {
|
||||
return { ...EMPTY_FORM, parent_id: parentDept.id }
|
||||
}
|
||||
return { ...EMPTY_FORM }
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const isTopLevel = mode === 'create_top' || (mode === 'edit' && editTarget?.depth === 0)
|
||||
const isEdit = mode === 'edit'
|
||||
|
||||
const titleMap = {
|
||||
create_top: '新增第一層部門',
|
||||
create_child: `新增子部門 (隸屬: ${parentDept?.name})`,
|
||||
edit: `編輯部門:${editTarget?.name}`,
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
name: form.name.trim(),
|
||||
code: form.code.trim().toUpperCase(),
|
||||
description: form.description.trim() || null,
|
||||
email_address: form.email_address.trim() || null,
|
||||
email_quota_mb: form.email_quota_mb,
|
||||
}
|
||||
if (form.name_en.trim()) payload.name_en = form.name_en.trim()
|
||||
|
||||
if (isEdit) {
|
||||
// 編輯:PUT,只有第一層可更新 email_domain
|
||||
if (isTopLevel) payload.email_domain = form.email_domain.trim() || null
|
||||
await apiClient.put(`/departments/${editTarget!.id}`, payload)
|
||||
} else {
|
||||
// 新增:POST
|
||||
payload.parent_id = form.parent_id ?? null
|
||||
if (isTopLevel) payload.email_domain = form.email_domain.trim() || null
|
||||
await apiClient.post('/departments/', payload)
|
||||
}
|
||||
|
||||
onSaved()
|
||||
onClose()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
err instanceof Error ? err.message : '操作失敗,請稍後再試'
|
||||
setError(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||||
<h2 className="text-lg font-semibold text-gray-900">{titleMap[mode]}</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="px-6 py-4 space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
部門名稱 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="例:業務發展部"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">英文名稱</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name_en}
|
||||
onChange={(e) => setForm({ ...form, name_en: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="例:Business Development"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
部門代碼 <span className="text-red-500">*</span>
|
||||
{isEdit && <span className="text-gray-400 font-normal ml-1">(建立後不可修改)</span>}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
disabled={isEdit}
|
||||
value={form.code}
|
||||
onChange={(e) => setForm({ ...form, code: e.target.value.toUpperCase() })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:text-gray-500"
|
||||
placeholder="例:BD"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isTopLevel && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
郵件網域
|
||||
<span className="text-gray-400 font-normal ml-1">(第一層專屬,例:ease.taipei)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.email_domain}
|
||||
onChange={(e) => setForm({ ...form, email_domain: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
|
||||
placeholder="ease.taipei"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">部門信箱</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email_address}
|
||||
onChange={(e) => setForm({ ...form, email_address: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
|
||||
placeholder="例:business@ease.taipei"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
信箱配額 (MB)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={512}
|
||||
value={form.email_quota_mb}
|
||||
onChange={(e) => setForm({ ...form, email_quota_mb: parseInt(e.target.value) || 5120 })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">{(form.email_quota_mb / 1024).toFixed(1)} GB</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">說明</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
placeholder="選填"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-60"
|
||||
>
|
||||
{saving ? '儲存中...' : '儲存'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 確認停用 Modal ──────────────────────────────────────────────────────
|
||||
function DeactivateModal({
|
||||
dept,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
dept: DepartmentTreeNode
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-sm">
|
||||
<div className="px-6 py-4 border-b">
|
||||
<h2 className="text-lg font-semibold text-gray-900">確認停用部門</h2>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<p className="text-gray-700">
|
||||
確定要停用 <strong>{dept.name}</strong> ({dept.code}) 嗎?
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
停用後該部門不再顯示於主要清單,現有成員歸屬不受影響。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 px-6 py-4 border-t">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700"
|
||||
>
|
||||
確認停用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 部門節點 ────────────────────────────────────────────────────────────
|
||||
function DepartmentNode({
|
||||
node,
|
||||
level = 0,
|
||||
onAddChild,
|
||||
onEdit,
|
||||
onDeactivate,
|
||||
}: {
|
||||
node: DepartmentTreeNode
|
||||
level?: number
|
||||
onAddChild: (parent: DepartmentTreeNode) => void
|
||||
onEdit: (dept: DepartmentTreeNode) => void
|
||||
onDeactivate: (dept: DepartmentTreeNode) => void
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(level === 0)
|
||||
const hasChildren = node.children && node.children.length > 0
|
||||
const indent = level * 24
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg mb-2">
|
||||
<div
|
||||
className={`flex items-center justify-between p-4 transition-colors ${
|
||||
level === 0 ? 'bg-white hover:bg-gray-50' : 'bg-gray-50 hover:bg-gray-100'
|
||||
}`}
|
||||
style={{ paddingLeft: `${16 + indent}px` }}
|
||||
>
|
||||
{/* 展開/收合按鈕區 */}
|
||||
<div
|
||||
className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer"
|
||||
onClick={() => hasChildren && setExpanded(!expanded)}
|
||||
>
|
||||
<span className="text-xl flex-shrink-0">
|
||||
{level === 0 ? (expanded ? '📂' : '📁') : level === 1 ? '📋' : '📌'}
|
||||
</span>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className={`font-semibold text-gray-900 ${level === 0 ? 'text-lg' : 'text-base'}`}>
|
||||
{node.name}
|
||||
</h3>
|
||||
{node.name_en && (
|
||||
<span className="text-xs text-gray-500">{node.name_en}</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded">{node.code}</span>
|
||||
{level === 0 && node.email_domain && (
|
||||
<span className="text-xs text-blue-600 bg-blue-50 px-2 py-0.5 rounded font-mono">
|
||||
@{node.email_domain}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{node.email_address && (
|
||||
<p className="text-sm text-blue-600 font-mono mt-0.5">{node.email_address}</p>
|
||||
)}
|
||||
{!node.email_address && node.effective_email_domain && level > 0 && (
|
||||
<p className="text-xs text-gray-400 mt-0.5">繼承網域: @{node.effective_email_domain}</p>
|
||||
)}
|
||||
{node.description && (
|
||||
<p className="text-xs text-gray-500 mt-0.5">{node.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右側資訊 + 操作按鈕 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0 ml-4">
|
||||
<span className="text-sm text-gray-500 whitespace-nowrap">{node.member_count} 位成員</span>
|
||||
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-gray-400 hover:text-gray-600 p-1"
|
||||
>
|
||||
<svg className={`w-4 h-4 transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 操作按鈕 */}
|
||||
<button
|
||||
onClick={() => onAddChild(node)}
|
||||
title="新增子部門"
|
||||
className="text-xs text-green-600 border border-green-200 bg-green-50 hover:bg-green-100 rounded px-2 py-1"
|
||||
>
|
||||
+ 子部門
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onEdit(node)}
|
||||
title="編輯"
|
||||
className="text-xs text-blue-600 border border-blue-200 bg-blue-50 hover:bg-blue-100 rounded px-2 py-1"
|
||||
>
|
||||
編輯
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeactivate(node)}
|
||||
title="停用"
|
||||
className="text-xs text-red-500 border border-red-200 bg-red-50 hover:bg-red-100 rounded px-2 py-1"
|
||||
>
|
||||
停用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 子部門 */}
|
||||
{expanded && hasChildren && (
|
||||
<div className="border-t border-gray-100 divide-y divide-gray-100">
|
||||
{node.children.map((child) => (
|
||||
<DepartmentNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
onAddChild={onAddChild}
|
||||
onEdit={onEdit}
|
||||
onDeactivate={onDeactivate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Modal 狀態類型 ───────────────────────────────────────────────────────
|
||||
type ModalState =
|
||||
| { type: 'create_top' }
|
||||
| { type: 'create_child'; parent: DepartmentTreeNode }
|
||||
| { type: 'edit'; target: DepartmentTreeNode }
|
||||
| { type: 'deactivate'; target: DepartmentTreeNode }
|
||||
| null
|
||||
|
||||
// ─── 主頁面 ──────────────────────────────────────────────────────────────
|
||||
export default function OrganizationPage() {
|
||||
const { data: session, status } = useSession()
|
||||
const router = useRouter()
|
||||
const [tree, setTree] = useState<DepartmentTreeNode[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [modal, setModal] = useState<ModalState>(null)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'unauthenticated') router.push('/auth/signin')
|
||||
}, [status, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'authenticated') fetchTree()
|
||||
}, [status])
|
||||
|
||||
const fetchTree = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await apiClient.get<DepartmentTreeNode[]>('/departments/tree')
|
||||
setTree(data)
|
||||
} catch {
|
||||
setError('無法載入組織架構')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeactivate = async (dept: DepartmentTreeNode) => {
|
||||
setActionError(null)
|
||||
try {
|
||||
await apiClient.delete(`/departments/${dept.id}`)
|
||||
setModal(null)
|
||||
fetchTree()
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : '停用失敗'
|
||||
setActionError(msg)
|
||||
setModal(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'loading' || loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-lg text-gray-500">載入中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session) return null
|
||||
|
||||
const domains = tree
|
||||
.filter((n) => n.is_top_level && n.email_domain)
|
||||
.map((n) => ({ domain: n.email_domain!, name: n.name }))
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* 頁首 */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">組織架構</h1>
|
||||
<p className="mt-1 text-gray-600">維護公司部門樹狀結構</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setModal({ type: 'create_top' })}
|
||||
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700 flex items-center gap-2"
|
||||
>
|
||||
<span className="text-lg leading-none">+</span>
|
||||
新增第一層部門
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">{error}</div>
|
||||
)}
|
||||
{actionError && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">{actionError}</div>
|
||||
)}
|
||||
|
||||
{/* 樹狀結構 */}
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">匠耘 Porsche World</h2>
|
||||
|
||||
{tree.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 mb-4">尚未建立任何部門</p>
|
||||
<button
|
||||
onClick={() => setModal({ type: 'create_top' })}
|
||||
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
建立第一個部門
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tree.map((node) => (
|
||||
<DepartmentNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
level={0}
|
||||
onAddChild={(parent) => setModal({ type: 'create_child', parent })}
|
||||
onEdit={(target) => setModal({ type: 'edit', target })}
|
||||
onDeactivate={(target) => setModal({ type: 'deactivate', target })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 網域說明 */}
|
||||
{domains.length > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-blue-900 mb-2">網域配置說明</h3>
|
||||
<div className="text-sm text-blue-800 space-y-1">
|
||||
{domains.map(({ domain, name }) => (
|
||||
<p key={domain}>
|
||||
• <strong>{domain}</strong> — {name} 專用網域
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-blue-700 mt-2">員工信箱根據所屬第一層部門的網域設定自動產生</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
{modal?.type === 'create_top' && (
|
||||
<DepartmentModal
|
||||
mode="create_top"
|
||||
onClose={() => setModal(null)}
|
||||
onSaved={fetchTree}
|
||||
/>
|
||||
)}
|
||||
{modal?.type === 'create_child' && (
|
||||
<DepartmentModal
|
||||
mode="create_child"
|
||||
parentDept={modal.parent}
|
||||
onClose={() => setModal(null)}
|
||||
onSaved={fetchTree}
|
||||
/>
|
||||
)}
|
||||
{modal?.type === 'edit' && (
|
||||
<DepartmentModal
|
||||
mode="edit"
|
||||
editTarget={modal.target}
|
||||
onClose={() => setModal(null)}
|
||||
onSaved={fetchTree}
|
||||
/>
|
||||
)}
|
||||
{modal?.type === 'deactivate' && (
|
||||
<DeactivateModal
|
||||
dept={modal.target}
|
||||
onClose={() => setModal(null)}
|
||||
onConfirm={() => handleDeactivate(modal.target)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user