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:
53
frontend/app/company-info/layout.tsx
Normal file
53
frontend/app/company-info/layout.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Company Info 佈局
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect } from 'react'
|
||||
import { Sidebar } from '@/components/layout/sidebar'
|
||||
import { Breadcrumb } from '@/components/layout/breadcrumb'
|
||||
|
||||
export default function CompanyInfoLayout({ children }: { children: React.ReactNode }) {
|
||||
const { data: session, status } = useSession()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'unauthenticated') {
|
||||
router.push('/auth/signin')
|
||||
}
|
||||
}, [status, router])
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-indigo-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">載入中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 flex-shrink-0">
|
||||
<Sidebar />
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="p-8">
|
||||
<Breadcrumb />
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
430
frontend/app/company-info/page.tsx
Normal file
430
frontend/app/company-info/page.tsx
Normal file
@@ -0,0 +1,430 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import apiClient from '@/lib/api-client'
|
||||
import AlertDialog from '@/components/ui/AlertDialog'
|
||||
|
||||
interface CompanyData {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
name_eng: string | null
|
||||
tax_id: string | null
|
||||
prefix: string
|
||||
tel: string | null
|
||||
add: string | null
|
||||
url: string | null
|
||||
keycloak_realm: string | null
|
||||
is_sysmana: boolean
|
||||
plan_id: string
|
||||
max_users: number
|
||||
storage_quota_gb: number
|
||||
status: string
|
||||
is_active: boolean
|
||||
edit_by: string | null
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export default function CompanyInfoPage() {
|
||||
const [companyData, setCompanyData] = useState<CompanyData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
// 表單資料
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
name_eng: '',
|
||||
tax_id: '',
|
||||
tel: '',
|
||||
add: '',
|
||||
url: '',
|
||||
})
|
||||
|
||||
// 對話框狀態
|
||||
const [alertDialog, setAlertDialog] = useState<{
|
||||
isOpen: boolean
|
||||
title: string
|
||||
message: string
|
||||
type: 'info' | 'warning' | 'error' | 'success'
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'info',
|
||||
})
|
||||
|
||||
// 載入公司資料
|
||||
useEffect(() => {
|
||||
loadCompanyData()
|
||||
}, [])
|
||||
|
||||
const loadCompanyData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response: any = await apiClient.get('/tenants/current')
|
||||
setCompanyData(response)
|
||||
setFormData({
|
||||
name: response.name || '',
|
||||
name_eng: response.name_eng || '',
|
||||
tax_id: response.tax_id || '',
|
||||
tel: response.tel || '',
|
||||
add: response.add || '',
|
||||
url: response.url || '',
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load company data:', error)
|
||||
setAlertDialog({
|
||||
isOpen: true,
|
||||
title: '載入失敗',
|
||||
message: error.response?.data?.detail || '無法載入公司資料',
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
if (companyData) {
|
||||
setFormData({
|
||||
name: companyData.name || '',
|
||||
name_eng: companyData.name_eng || '',
|
||||
tax_id: companyData.tax_id || '',
|
||||
tel: companyData.tel || '',
|
||||
add: companyData.add || '',
|
||||
url: companyData.url || '',
|
||||
})
|
||||
}
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
// 驗證
|
||||
if (!formData.name) {
|
||||
setAlertDialog({
|
||||
isOpen: true,
|
||||
title: '欄位驗證',
|
||||
message: '公司名稱為必填欄位',
|
||||
type: 'warning',
|
||||
})
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 驗證統一編號格式 (8位數字)
|
||||
if (formData.tax_id && (!/^\d{8}$/.test(formData.tax_id))) {
|
||||
setAlertDialog({
|
||||
isOpen: true,
|
||||
title: '欄位驗證',
|
||||
message: '統一編號必須為8位數字',
|
||||
type: 'warning',
|
||||
})
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 送出更新
|
||||
await apiClient.patch('/tenants/current', formData)
|
||||
|
||||
// 重新載入資料
|
||||
await loadCompanyData()
|
||||
setIsEditing(false)
|
||||
|
||||
setAlertDialog({
|
||||
isOpen: true,
|
||||
title: '儲存成功',
|
||||
message: '公司資料已成功更新',
|
||||
type: 'success',
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update company data:', error)
|
||||
setAlertDialog({
|
||||
isOpen: true,
|
||||
title: '儲存失敗',
|
||||
message: error.response?.data?.detail || '更新失敗,請稍後再試',
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">載入中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!companyData) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-red-500">無法載入公司資料</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">公司資料維護</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">管理您的公司基本資料</p>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
編輯資料
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
{/* Card Header */}
|
||||
<div className="bg-gradient-to-r from-blue-900 to-blue-800 px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{companyData.name} - 基本資料
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Card Body */}
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
value={companyData.code}
|
||||
disabled
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 text-sm cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
員工編號前綴 (不可修改)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={companyData.prefix}
|
||||
disabled
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 text-sm cursor-not-allowed"
|
||||
/>
|
||||
</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)}
|
||||
disabled={!isEditing}
|
||||
className={`w-full px-3 py-2 border border-gray-300 rounded-lg text-sm ${
|
||||
isEditing
|
||||
? 'text-gray-900 placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
: 'bg-gray-50 text-gray-700 cursor-not-allowed'
|
||||
}`}
|
||||
placeholder="例如: 匠耘營運有限公司"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
公司英文名稱
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name_eng}
|
||||
onChange={(e) => handleChange('name_eng', e.target.value)}
|
||||
disabled={!isEditing}
|
||||
className={`w-full px-3 py-2 border border-gray-300 rounded-lg text-sm ${
|
||||
isEditing
|
||||
? 'text-gray-900 placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
: 'bg-gray-50 text-gray-700 cursor-not-allowed'
|
||||
}`}
|
||||
placeholder="例如: Porsche World Co., Ltd."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: 統一編號 + 公司電話 */}
|
||||
<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="text"
|
||||
value={formData.tax_id}
|
||||
onChange={(e) => handleChange('tax_id', e.target.value)}
|
||||
disabled={!isEditing}
|
||||
className={`w-full px-3 py-2 border border-gray-300 rounded-lg text-sm ${
|
||||
isEditing
|
||||
? 'text-gray-900 placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
: 'bg-gray-50 text-gray-700 cursor-not-allowed'
|
||||
}`}
|
||||
placeholder="例如: 12345678"
|
||||
maxLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
公司電話
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.tel}
|
||||
onChange={(e) => handleChange('tel', e.target.value)}
|
||||
disabled={!isEditing}
|
||||
className={`w-full px-3 py-2 border border-gray-300 rounded-lg text-sm ${
|
||||
isEditing
|
||||
? 'text-gray-900 placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
: 'bg-gray-50 text-gray-700 cursor-not-allowed'
|
||||
}`}
|
||||
placeholder="例如: 02-12345678"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4: 公司地址 */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
公司地址
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.add}
|
||||
onChange={(e) => handleChange('add', e.target.value)}
|
||||
disabled={!isEditing}
|
||||
className={`w-full px-3 py-2 border border-gray-300 rounded-lg text-sm ${
|
||||
isEditing
|
||||
? 'text-gray-900 placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
: 'bg-gray-50 text-gray-700 cursor-not-allowed'
|
||||
}`}
|
||||
placeholder="例如: 台北市信義區信義路五段7號"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 5: 公司網站 */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
公司網站
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.url}
|
||||
onChange={(e) => handleChange('url', e.target.value)}
|
||||
disabled={!isEditing}
|
||||
className={`w-full px-3 py-2 border border-gray-300 rounded-lg text-sm ${
|
||||
isEditing
|
||||
? 'text-gray-900 placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
: 'bg-gray-50 text-gray-700 cursor-not-allowed'
|
||||
}`}
|
||||
placeholder="例如: https://www.porscheworld.tw"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 系統資訊 (唯讀) */}
|
||||
<div className="border-t pt-4 mt-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">系統資訊</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">訂閱方案</label>
|
||||
<div className="text-sm text-gray-700">{companyData.plan_id}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">最大用戶數</label>
|
||||
<div className="text-sm text-gray-700">{companyData.max_users}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">儲存配額 (GB)</label>
|
||||
<div className="text-sm text-gray-700">{companyData.storage_quota_gb}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">狀態</label>
|
||||
<div className="text-sm text-gray-700">
|
||||
<span className={`px-2 py-1 rounded text-xs ${
|
||||
companyData.status === 'active' ? 'bg-green-100 text-green-800' :
|
||||
companyData.status === 'trial' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{companyData.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">最後編輯者</label>
|
||||
<div className="text-sm text-gray-700">{companyData.edit_by || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">更新時間</label>
|
||||
<div className="text-sm text-gray-700">
|
||||
{companyData.updated_at ? new Date(companyData.updated_at).toLocaleString('zh-TW') : '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按鈕 */}
|
||||
{isEditing && (
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium"
|
||||
disabled={saving}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 text-sm font-medium"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? '儲存中...' : '儲存'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Alert Dialog */}
|
||||
<AlertDialog
|
||||
isOpen={alertDialog.isOpen}
|
||||
title={alertDialog.title}
|
||||
message={alertDialog.message}
|
||||
type={alertDialog.type}
|
||||
onConfirm={() => setAlertDialog({ ...alertDialog, isOpen: false })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user