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:
2026-02-23 20:12:43 +08:00
commit 360533393f
386 changed files with 70353 additions and 0 deletions

View 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">&times;</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>
)
}