Files
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

595 lines
21 KiB
TypeScript
Raw Permalink 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 { 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>
)
}