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>
431 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|