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

431 lines
14 KiB
TypeScript

'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>
)
}