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,213 @@
/**
* NextAuth 4 API Route Handler with Redis Session Storage
*
* 架構說明:
* 1. Keycloak SSO - 統一認證入口
* 2. Redis - 集中式 session storage (解決 Cookie 4KB 限制)
* 3. Cookie - 只存 session_key (64 bytes)
*
* 多系統共享:
* - hr.ease.taipei, mail.ease.taipei, calendar.ease.taipei 等
* - 共用同一個 Redis session (透過 Keycloak user_id)
* - Token refresh 自動同步到所有系統
*/
import NextAuth, { NextAuthOptions } from 'next-auth'
import KeycloakProvider from 'next-auth/providers/keycloak'
import Redis from 'ioredis'
// Redis 客戶端 (DB 0 專用於 Session Storage)
const redis = new Redis({
host: process.env.REDIS_HOST || '10.1.0.20',
port: parseInt(process.env.REDIS_PORT || '6379'),
password: process.env.REDIS_PASSWORD,
db: 0, // Session Storage 專用
retryStrategy: (times) => {
const delay = Math.min(times * 50, 2000)
return delay
},
})
// Redis 連線錯誤處理
redis.on('error', (err) => {
console.error('[Redis] Connection error:', err)
})
redis.on('connect', () => {
console.log('[Redis] Connected to session storage')
})
const authOptions: NextAuthOptions = {
providers: [
KeycloakProvider({
clientId: process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID!,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
issuer: `${process.env.NEXT_PUBLIC_KEYCLOAK_URL}/realms/${process.env.NEXT_PUBLIC_KEYCLOAK_REALM}`,
}),
],
session: {
strategy: 'jwt',
maxAge: 8 * 60 * 60, // 8 小時
updateAge: 60 * 60, // 每 1 小時檢查一次
},
events: {
// 登出時清除 Redis session (所有系統同步登出)
async signOut({ token }) {
if (token?.sub) {
const sessionKey = `session:${token.sub}`
await redis.del(sessionKey)
console.log('[EVENT] Signed out, Redis session deleted:', sessionKey)
}
},
},
callbacks: {
async jwt({ token, account, profile, trigger }) {
const userId = token.sub || profile?.sub
if (!userId) return token
console.log('[JWT CALLBACK]', { trigger, userId: userId.substring(0, 8) })
// 初次登入 - 儲存 Keycloak tokens 到 Redis
if (account && profile) {
const sessionData = {
sub: profile.sub,
email: profile.email,
name: profile.name,
accessToken: account.access_token,
refreshToken: account.refresh_token,
expiresAt: account.expires_at,
createdAt: Math.floor(Date.now() / 1000),
}
// 存入 Redis (Key: session:{user_id})
const sessionKey = `session:${userId}`
await redis.setex(
sessionKey,
8 * 60 * 60, // TTL: 8 hours
JSON.stringify(sessionData)
)
console.log('[JWT CALLBACK] Stored session to Redis:', sessionKey)
// Cookie 只存輕量資料 (user_id)
return {
sub: userId,
email: profile.email,
name: profile.name,
sessionKey, // 引用 Redis 的 key
}
}
// 後續請求 - 從 Redis 讀取 tokens
const sessionKey = token.sessionKey as string || `session:${userId}`
const sessionDataStr = await redis.get(sessionKey)
if (!sessionDataStr) {
console.error('[JWT CALLBACK] Session not found in Redis:', sessionKey)
return { ...token, error: 'SessionNotFound' }
}
const sessionData = JSON.parse(sessionDataStr)
// 檢查 access_token 是否即將過期 (提前 1 分鐘刷新)
const now = Math.floor(Date.now() / 1000)
const expiresAt = sessionData.expiresAt as number
if (now < expiresAt - 60) {
// Token 仍有效 - 更新 Redis TTL (Sliding Expiration)
await redis.expire(sessionKey, 8 * 60 * 60)
console.log('[JWT CALLBACK] Token valid, TTL refreshed')
return token
}
// Token 即將過期 - 使用 refresh_token 更新
console.log('[JWT CALLBACK] Refreshing token...')
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_KEYCLOAK_URL}/realms/${process.env.NEXT_PUBLIC_KEYCLOAK_REALM}/protocol/openid-connect/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
grant_type: 'refresh_token',
refresh_token: sessionData.refreshToken,
}),
}
)
if (!response.ok) {
console.error('[JWT CALLBACK] Refresh failed:', response.status)
await redis.del(sessionKey) // 刪除無效 session
return { ...token, error: 'RefreshTokenError' }
}
const refreshedTokens = await response.json()
// 更新 Redis session
const updatedSessionData = {
...sessionData,
accessToken: refreshedTokens.access_token,
refreshToken: refreshedTokens.refresh_token ?? sessionData.refreshToken,
expiresAt: now + refreshedTokens.expires_in,
}
await redis.setex(sessionKey, 8 * 60 * 60, JSON.stringify(updatedSessionData))
console.log('[JWT CALLBACK] Token refreshed and updated in Redis')
return token
} catch (error) {
console.error('[JWT CALLBACK] Refresh error:', error)
await redis.del(sessionKey)
return { ...token, error: 'RefreshTokenError' }
}
},
async session({ session, token }) {
console.log('[SESSION CALLBACK] Called')
// 處理錯誤狀態
if (token?.error) {
console.error('[SESSION CALLBACK] Session error:', token.error)
return { ...session, error: token.error }
}
if (!token.sub) {
return { ...session, error: 'InvalidSession' }
}
// 從 Redis 讀取完整 session 資料
const sessionKey = token.sessionKey as string || `session:${token.sub}`
const sessionDataStr = await redis.get(sessionKey)
if (!sessionDataStr) {
console.error('[SESSION CALLBACK] Session expired or not found')
return { ...session, error: 'SessionExpired' }
}
const sessionData = JSON.parse(sessionDataStr)
// 填充 session 物件 (前端可用)
if (session.user) {
session.user.id = sessionData.sub
session.user.email = sessionData.email
session.user.name = sessionData.name
session.accessToken = sessionData.accessToken // ← 從 Redis 取得!
// Tenant 資訊 (如果需要可以存在 Redis)
if (sessionData.tenant) {
;(session.user as any).tenant = sessionData.tenant
}
console.log('[SESSION CALLBACK] Session loaded from Redis')
}
return session
},
},
secret: process.env.NEXTAUTH_SECRET,
debug: true,
}
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }

View File

@@ -0,0 +1,68 @@
/**
* 認證錯誤頁面
*/
'use client'
import { useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { Suspense } from 'react'
function ErrorContent() {
const searchParams = useSearchParams()
const error = searchParams.get('error')
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-100">
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
<div className="text-center mb-8">
<div className="mx-auto w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4">
<svg
className="w-8 h-8 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2"></h1>
<p className="text-gray-600"></p>
</div>
{error && (
<div className="mb-6 p-4 bg-gray-50 rounded-md">
<p className="text-sm text-gray-700">: {error}</p>
</div>
)}
<div className="space-y-4">
<Link
href="/auth/signin"
className="block w-full text-center px-4 py-3 border border-transparent rounded-md shadow-sm text-base font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
>
</Link>
<Link
href="/"
className="block w-full text-center px-4 py-3 border border-gray-300 rounded-md shadow-sm text-base font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
>
</Link>
</div>
</div>
</div>
)
}
export default function ErrorPage() {
return (
<Suspense fallback={<div>...</div>}>
<ErrorContent />
</Suspense>
)
}

View File

@@ -0,0 +1,73 @@
/**
* 登入頁面
*/
'use client'
import { signIn } from 'next-auth/react'
import { useSearchParams } from 'next/navigation'
import { Suspense } from 'react'
function SignInContent() {
const searchParams = useSearchParams()
const error = searchParams.get('error')
const callbackUrl = searchParams.get('callbackUrl') || '/dashboard'
const handleSignIn = () => {
signIn('keycloak', { callbackUrl })
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">HR Portal</h1>
<p className="text-gray-600"></p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-600">
{error === 'OAuthCallback'
? '登入失敗,請重試'
: '發生錯誤,請稍後再試'}
</p>
</div>
)}
<div className="space-y-4">
<button
onClick={handleSignIn}
className="w-full flex items-center justify-center px-4 py-3 border border-transparent rounded-md shadow-sm text-base font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
>
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
/>
</svg>
使 Keycloak SSO
</button>
</div>
<div className="mt-6 text-center text-sm text-gray-500">
<p>© 2026 Porsche World</p>
</div>
</div>
</div>
)
}
export default function SignInPage() {
return (
<Suspense fallback={<div>...</div>}>
<SignInContent />
</Suspense>
)
}

View File

@@ -0,0 +1,53 @@
/**
* Business Units 佈局
*/
'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 BusinessUnitsLayout({ 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>
)
}

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

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

View File

@@ -0,0 +1,76 @@
/**
* Dashboard 佈局
*/
'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 DashboardLayout({ children }: { children: React.ReactNode }) {
const { data: session, status } = useSession()
const router = useRouter()
useEffect(() => {
console.log('[Dashboard] ========== Dashboard Auth Check ==========')
console.log('[Dashboard] Status:', status)
console.log('[Dashboard] Has session:', !!session)
console.log('[Dashboard] Has user:', !!session?.user)
console.log('[Dashboard] User email:', session?.user?.email)
console.log('[Dashboard] ===========================================')
// 完全移除重定向邏輯,讓 session 自然載入
// NextAuth 會自動處理 session 同步
}, [status, session])
// 載入中時顯示 loading
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"> Session...</p>
<p className="text-xs text-gray-400 mt-2">Status: {status}</p>
</div>
</div>
)
}
// 如果沒有 session,顯示登入提示 (不要自動重定向)
if (!session || !session.user) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="bg-white p-8 rounded-lg shadow-md text-center max-w-md">
<h2 className="text-2xl font-bold text-gray-900 mb-4"></h2>
<p className="text-gray-600 mb-6"> Session </p>
<p className="text-sm text-gray-500 mb-4">Status: {status}</p>
<button
onClick={() => router.push('/auth/signin')}
className="bg-indigo-600 text-white px-6 py-2 rounded-md hover:bg-indigo-700"
>
</button>
</div>
</div>
)
}
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>
)
}

View File

@@ -0,0 +1,220 @@
/**
* 主控台首頁
*/
'use client'
import { useSession } from 'next-auth/react'
import { useEffect, useState } from 'react'
import apiClient from '@/lib/api-client'
interface DashboardStats {
employeeCount: number
topDeptCount: number
totalDeptCount: number
pendingTaskCount: number
}
export default function DashboardPage() {
const { data: session } = useSession()
const [stats, setStats] = useState<DashboardStats>({
employeeCount: 0,
topDeptCount: 0,
totalDeptCount: 0,
pendingTaskCount: 0,
})
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchStats = async () => {
try {
setLoading(true)
// 員工數量
const employeesData: any = await apiClient.get('/employees/')
// 部門統計 (新架構: 從 tree API 取得)
let topDeptCount = 0
let totalDeptCount = 0
try {
const treeData: any = await apiClient.get('/departments/tree')
if (Array.isArray(treeData)) {
topDeptCount = treeData.length
const countAllNodes = (nodes: any[]): number => {
return nodes.reduce((sum: number, node: any) => {
return sum + 1 + (node.children ? countAllNodes(node.children) : 0)
}, 0)
}
totalDeptCount = countAllNodes(treeData)
}
} catch (error) {
console.warn('Failed to fetch department tree:', error)
}
setStats({
employeeCount: employeesData?.total || 0,
topDeptCount,
totalDeptCount,
pendingTaskCount: 0,
})
} catch (error) {
console.error('Failed to fetch dashboard stats:', error)
} finally {
setLoading(false)
}
}
if (session) {
fetchStats()
}
}, [session])
const statCards = [
{
name: '在職員工',
value: loading ? '...' : stats.employeeCount.toString(),
icon: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
),
color: 'bg-blue-500',
},
{
name: '第一層部門數',
value: loading ? '...' : stats.topDeptCount.toString(),
icon: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
),
color: 'bg-green-500',
},
{
name: '部門總數',
value: loading ? '...' : stats.totalDeptCount.toString(),
icon: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
),
color: 'bg-purple-500',
},
{
name: '待處理事項',
value: loading ? '...' : stats.pendingTaskCount.toString(),
icon: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
color: 'bg-orange-500',
},
]
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">!</h1>
<p className="mt-2 text-gray-600">
{session?.user?.name || '管理員'}, HR Portal
</p>
</div>
{/* 統計卡片 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{statCards.map((stat) => (
<div key={stat.name} className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">{stat.name}</p>
<p className="mt-2 text-3xl font-bold text-gray-900">{stat.value}</p>
</div>
<div className={`${stat.color} p-3 rounded-lg text-white`}>{stat.icon}</div>
</div>
</div>
))}
</div>
{/* 快速操作 */}
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
<h2 className="text-xl font-bold text-gray-900 mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<button className="flex items-center justify-center px-4 py-3 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 transition-colors">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</button>
<button className="flex items-center justify-center px-4 py-3 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition-colors">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</button>
<button className="flex items-center justify-center px-4 py-3 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition-colors">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
</div>
</div>
{/* 最近活動 */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4"></h2>
<div className="space-y-4">
<div className="flex items-start">
<div className="flex-shrink-0 w-2 h-2 mt-2 rounded-full bg-blue-500"></div>
<div className="ml-3">
<p className="text-sm font-medium text-gray-900"></p>
<p className="text-sm text-gray-500">使 HR Portal</p>
<p className="text-xs text-gray-400 mt-1"></p>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,351 @@
'use client'
import { useState, useEffect } from 'react'
import apiClient from '@/lib/api-client'
import AlertDialog from '@/components/ui/AlertDialog'
interface Department {
id?: number
parent_id: number | null
code: string
name: string
name_en: string | null
email_domain: string | null
email_address: string | null
email_quota_mb: number
description: string | null
is_active: boolean
}
interface ParentDepartment {
id: number
name: string
depth: number
}
interface Props {
department: Department | null
parentDepartments: ParentDepartment[]
onClose: () => void
onSave: (isEdit: boolean) => void
}
export default function DepartmentFormModal({ department: editingDepartment, parentDepartments, onClose, onSave }: Props) {
const [formData, setFormData] = useState<Department>({
parent_id: null,
code: '',
name: '',
name_en: null,
email_domain: null,
email_address: null,
email_quota_mb: 5120,
description: null,
is_active: true,
})
const [loading, setLoading] = useState(false)
const [isTopLevel, setIsTopLevel] = useState(true)
// 對話框狀態
const [alertDialog, setAlertDialog] = useState<{
isOpen: boolean
title: string
message: string
type: 'info' | 'warning' | 'error' | 'success'
}>({
isOpen: false,
title: '',
message: '',
type: 'info',
})
// 如果是編輯模式,填入現有資料
useEffect(() => {
if (editingDepartment) {
setFormData(editingDepartment)
setIsTopLevel(editingDepartment.parent_id === null)
}
}, [editingDepartment])
const handleChange = (field: keyof Department, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }))
}
const handleParentChange = (value: string) => {
const parentId = value === '' ? null : parseInt(value)
setFormData(prev => ({ ...prev, parent_id: parentId }))
setIsTopLevel(parentId === null)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
// 驗證
if (!formData.code || !formData.name) {
setAlertDialog({
isOpen: true,
title: '欄位驗證',
message: '請填寫部門代碼和名稱',
type: 'warning',
})
setLoading(false)
return
}
// 第一層部門必須填寫郵件網域
if (isTopLevel && !formData.email_domain) {
setAlertDialog({
isOpen: true,
title: '欄位驗證',
message: '第一層部門必須填寫郵件網域',
type: 'warning',
})
setLoading(false)
return
}
// 準備資料
const submitData: any = {
parent_id: formData.parent_id,
code: formData.code,
name: formData.name,
name_en: formData.name_en,
email_address: formData.email_address,
email_quota_mb: formData.email_quota_mb,
description: formData.description,
is_active: formData.is_active,
}
// 只有第一層部門才送 email_domain
if (isTopLevel) {
submitData.email_domain = formData.email_domain
}
const isEdit = !!editingDepartment
if (isEdit) {
// 更新
await apiClient.put(`/departments/${editingDepartment.id}`, submitData)
} else {
// 新增
await apiClient.post('/departments', submitData)
}
onSave(isEdit)
onClose()
} catch (error: any) {
console.error('Failed to save department:', error)
setAlertDialog({
isOpen: true,
title: '儲存失敗',
message: error.response?.data?.detail || '儲存失敗,請稍後再試',
type: 'error',
})
} finally {
setLoading(false)
}
}
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 animate-fadeIn"
onClick={onClose}
>
<div
className="bg-white rounded-xl shadow-2xl w-full max-w-5xl max-h-[90vh] overflow-hidden animate-slideIn"
onClick={(e) => e.stopPropagation()}
>
{/* Card Header */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 px-5 py-3.5 flex justify-between items-center">
<h2 className="text-lg font-semibold text-white">
{formData.name || '部門資料'} - {editingDepartment ? '編輯作業' : '新增作業'}
</h2>
<button
type="button"
onClick={onClose}
className="text-white hover:text-blue-100 transition-colors"
aria-label="關閉"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Card Body - Scrollable */}
<div className="overflow-y-auto max-h-[calc(90vh-180px)]">
<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>
<select
value={formData.parent_id === null ? '' : formData.parent_id}
onChange={(e) => handleParentChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
>
<option value=""> ()</option>
{parentDepartments.map(dept => (
<option key={dept.id} value={dept.id}>
{dept.name}
</option>
))}
</select>
</div>
<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.code}
onChange={(e) => handleChange('code', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
placeholder="例如: BD"
required
/>
</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)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
placeholder="例如: 業務發展部"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="text"
value={formData.name_en || ''}
onChange={(e) => handleChange('name_en', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
placeholder="例如: Business Development"
/>
</div>
</div>
{/* Row 3: 郵件網域 (只在第一層顯示) */}
{isTopLevel && (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.email_domain || ''}
onChange={(e) => handleChange('email_domain', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
placeholder="例如: ease.taipei"
required
/>
</div>
)}
{/* Row 4: 部門信箱 + 信箱配額 */}
<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="email"
value={formData.email_address || ''}
onChange={(e) => handleChange('email_address', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
placeholder="例如: bd@ease.taipei"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
(MB)
</label>
<input
type="number"
value={formData.email_quota_mb}
onChange={(e) => handleChange('email_quota_mb', parseInt(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
min={0}
/>
</div>
</div>
{/* 說明 */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<textarea
value={formData.description || ''}
onChange={(e) => handleChange('description', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
rows={2}
placeholder="部門說明..."
/>
</div>
{/* 啟用 */}
<div>
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => handleChange('is_active', e.target.checked)}
className="mr-2 w-4 h-4"
/>
<span className="text-sm font-medium text-gray-700"></span>
</label>
</div>
</form>
</div>
{/* Card Footer */}
<div className="border-t bg-gray-50 px-5 py-2.5 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-1.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors text-sm font-medium"
disabled={loading}
>
</button>
<button
type="submit"
onClick={handleSubmit}
className="px-4 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 text-sm font-medium"
disabled={loading}
>
{loading ? '儲存中...' : '儲存'}
</button>
</div>
{/* Alert Dialog */}
<AlertDialog
isOpen={alertDialog.isOpen}
title={alertDialog.title}
message={alertDialog.message}
type={alertDialog.type}
onConfirm={() => setAlertDialog({ ...alertDialog, isOpen: false })}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,53 @@
/**
* Departments 佈局
*/
'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 DepartmentsLayout({ 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>
)
}

View File

@@ -0,0 +1,444 @@
'use client'
import { useState, useEffect } from 'react'
import apiClient from '@/lib/api-client'
import AlertDialog from '@/components/ui/AlertDialog'
import ConfirmDialog from '@/components/ui/ConfirmDialog'
import DepartmentFormModal from './DepartmentFormModal'
interface Department {
id: number
tenant_id: number
parent_id: number | null
code: string
name: string
name_en: string | null
depth: number
email_domain: string | null
effective_email_domain: string | null
email_address: string | null
email_quota_mb: number
description: string | null
is_active: boolean
member_count: number
}
interface ParentDepartment {
id: number
name: string
depth: number
}
export default function DepartmentsPage() {
const [departments, setDepartments] = useState<Department[]>([])
const [parentDepartments, setParentDepartments] = useState<ParentDepartment[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingDepartment, setEditingDepartment] = useState<Department | null>(null)
// 篩選條件
const [filterDepth, setFilterDepth] = useState<string>('all')
const [filterActive, setFilterActive] = useState<string>('all')
const [filterParent, setFilterParent] = useState<string>('all')
// 分頁
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(5)
// 對話框
const [alertDialog, setAlertDialog] = useState<{
isOpen: boolean
title: string
message: string
type: 'info' | 'warning' | 'error' | 'success'
}>({
isOpen: false,
title: '',
message: '',
type: 'info',
})
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean
title: string
message: string
onConfirm: () => void
}>({
isOpen: false,
title: '',
message: '',
onConfirm: () => {},
})
useEffect(() => {
loadDepartments()
loadParentDepartments()
}, [])
const loadDepartments = async () => {
try {
setLoading(true)
const response: any = await apiClient.get('/departments?include_inactive=true')
setDepartments(response || [])
} catch (error: any) {
console.error('Failed to load departments:', error)
setAlertDialog({
isOpen: true,
title: '載入失敗',
message: error.response?.data?.detail || '無法載入部門資料',
type: 'error',
})
} finally {
setLoading(false)
}
}
const loadParentDepartments = async () => {
try {
// 載入所有部門作為上層部門選項
const response: any = await apiClient.get('/departments?include_inactive=false')
setParentDepartments(response || [])
} catch (error) {
console.error('Failed to load parent departments:', error)
}
}
const handleAdd = () => {
setEditingDepartment(null)
setShowModal(true)
}
const handleEdit = (department: Department) => {
setEditingDepartment(department)
setShowModal(true)
}
const handleDelete = (department: Department) => {
setConfirmDialog({
isOpen: true,
title: '確認刪除',
message: `確定要刪除部門「${department.name}」嗎?此操作無法復原。`,
onConfirm: async () => {
try {
await apiClient.delete(`/departments/${department.id}`)
loadDepartments()
setAlertDialog({
isOpen: true,
title: '刪除成功',
message: '部門已成功刪除',
type: 'success',
})
} catch (error: any) {
setAlertDialog({
isOpen: true,
title: '刪除失敗',
message: error.response?.data?.detail || '刪除失敗,請稍後再試',
type: 'error',
})
}
setConfirmDialog({ ...confirmDialog, isOpen: false })
},
})
}
const handleToggleActive = async (department: Department) => {
try {
await apiClient.patch(`/departments/${department.id}`, {
is_active: !department.is_active,
})
loadDepartments()
setAlertDialog({
isOpen: true,
title: '切換成功',
message: `部門已${!department.is_active ? '啟用' : '停用'}`,
type: 'success',
})
} catch (error: any) {
setAlertDialog({
isOpen: true,
title: '切換失敗',
message: error.response?.data?.detail || '切換狀態失敗',
type: 'error',
})
}
}
// 篩選邏輯
const filteredDepartments = departments.filter(dept => {
if (filterDepth !== 'all' && dept.depth !== parseInt(filterDepth)) return false
if (filterActive === 'active' && !dept.is_active) return false
if (filterActive === 'inactive' && dept.is_active) return false
if (filterParent === 'top' && dept.parent_id !== null) return false
if (filterParent !== 'all' && filterParent !== 'top' && dept.parent_id !== parseInt(filterParent)) return false
return true
})
// 分頁邏輯
const totalPages = Math.ceil(filteredDepartments.length / pageSize)
const startIndex = (currentPage - 1) * pageSize
const paginatedDepartments = filteredDepartments.slice(startIndex, startIndex + pageSize)
const getParentName = (parentId: number | null) => {
if (!parentId) return '-'
const parent = departments.find(d => d.id === parentId)
return parent ? parent.name : '-'
}
const getDepthLabel = (depth: number) => {
return `${depth + 1}`
}
return (
<div>
{/* 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>
<button
onClick={handleAdd}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
>
+
</button>
</div>
{/* Filters */}
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200 mb-4">
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1"></label>
<select
value={filterDepth}
onChange={(e) => { setFilterDepth(e.target.value); setCurrentPage(1); }}
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all"></option>
<option value="0">1</option>
<option value="1">2</option>
<option value="2">3</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1"></label>
<select
value={filterActive}
onChange={(e) => { setFilterActive(e.target.value); setCurrentPage(1); }}
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all"></option>
<option value="active"></option>
<option value="inactive"></option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1"></label>
<select
value={filterParent}
onChange={(e) => { setFilterParent(e.target.value); setCurrentPage(1); }}
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all"></option>
<option value="top"></option>
{parentDepartments.map(dept => (
<option key={dept.id} value={dept.id}>{dept.name}</option>
))}
</select>
</div>
</div>
</div>
{/* Pagination Controls */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-700"></span>
<select
value={pageSize}
onChange={(e) => { setPageSize(parseInt(e.target.value)); setCurrentPage(1); }}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="5">5</option>
<option value="10">10</option>
<option value="15">15</option>
<option value="20">20</option>
<option value="25">25</option>
<option value="50">50</option>
</select>
<span className="text-sm text-gray-700"></span>
</div>
<div className="text-sm text-gray-700">
{filteredDepartments.length}
</div>
</div>
{/* DataTable */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-blue-900">
<tr>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-white uppercase tracking-wider">ID</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-white uppercase tracking-wider"></th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-white uppercase tracking-wider"></th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-white uppercase tracking-wider"></th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-white uppercase tracking-wider"></th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-white uppercase tracking-wider"></th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-white uppercase tracking-wider"></th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-white uppercase tracking-wider"></th>
<th className="px-4 py-2.5 text-center text-xs font-semibold text-white uppercase tracking-wider"></th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{loading ? (
<tr>
<td colSpan={9} className="px-4 py-8 text-center text-sm text-gray-500">
...
</td>
</tr>
) : paginatedDepartments.length === 0 ? (
<tr>
<td colSpan={9} className="px-4 py-8 text-center text-sm text-gray-500">
</td>
</tr>
) : (
paginatedDepartments.map((dept) => (
<tr key={dept.id} className="hover:bg-gray-50">
<td className="px-4 py-2.5 text-sm text-gray-900">{dept.id}</td>
<td className="px-4 py-2.5 text-sm text-gray-900">{getDepthLabel(dept.depth)}</td>
<td className="px-4 py-2.5 text-sm text-gray-900 font-medium">{dept.code}</td>
<td className="px-4 py-2.5 text-sm text-gray-900">{dept.name}</td>
<td className="px-4 py-2.5 text-sm text-gray-600">{getParentName(dept.parent_id)}</td>
<td className="px-4 py-2.5 text-sm text-gray-600">{dept.effective_email_domain || '-'}</td>
<td className="px-4 py-2.5 text-sm text-gray-900">{dept.member_count || 0}</td>
<td className="px-4 py-2.5">
<button
onClick={() => handleToggleActive(dept)}
className={`px-2 py-1 rounded text-xs font-medium cursor-pointer ${
dept.is_active
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-red-100 text-red-800 hover:bg-red-200'
}`}
>
{dept.is_active ? '是' : '否'}
</button>
</td>
<td className="px-4 py-2.5 text-center">
<div className="flex justify-center gap-2">
<button
onClick={() => handleEdit(dept)}
className="px-3 py-1 text-xs font-medium text-blue-600 hover:text-blue-800 border border-blue-600 hover:border-blue-800 rounded transition-colors"
>
</button>
<button
onClick={() => handleDelete(dept)}
className="px-3 py-1 text-xs font-medium text-red-600 hover:text-red-800 border border-red-600 hover:border-red-800 rounded transition-colors"
>
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="bg-gray-50 px-4 py-3 flex items-center justify-between border-t border-gray-200">
<div className="text-sm text-gray-700">
{startIndex + 1} - {Math.min(startIndex + pageSize, filteredDepartments.length)} {filteredDepartments.length}
</div>
<div className="flex gap-2">
<button
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
«
</button>
<button
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
className="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
{[...Array(totalPages)].map((_, i) => (
<button
key={i + 1}
onClick={() => setCurrentPage(i + 1)}
className={`px-3 py-1 text-sm border rounded ${
currentPage === i + 1
? 'bg-blue-600 text-white border-blue-600'
: 'border-gray-300 hover:bg-gray-100'
}`}
>
{i + 1}
</button>
))}
<button
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<button
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages}
className="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
»
</button>
</div>
</div>
)}
</div>
{/* Modal */}
{showModal && (
<DepartmentFormModal
department={editingDepartment}
parentDepartments={parentDepartments}
onClose={() => setShowModal(false)}
onSave={(isEdit: boolean) => {
setShowModal(false)
loadDepartments()
loadParentDepartments()
setAlertDialog({
isOpen: true,
title: '儲存成功',
message: `部門已成功${isEdit ? '更新' : '新增'}`,
type: 'success',
})
}}
/>
)}
{/* Alert Dialog */}
<AlertDialog
isOpen={alertDialog.isOpen}
title={alertDialog.title}
message={alertDialog.message}
type={alertDialog.type}
onConfirm={() => setAlertDialog({ ...alertDialog, isOpen: false })}
/>
{/* Confirm Dialog */}
<ConfirmDialog
isOpen={confirmDialog.isOpen}
title={confirmDialog.title}
message={confirmDialog.message}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog({ ...confirmDialog, isOpen: false })}
/>
</div>
)
}

View File

@@ -0,0 +1,322 @@
'use client'
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter, useParams } from 'next/navigation'
interface Employee {
id: number
employee_id: string
username_base: string
legal_name: string
english_name: string | null
phone: string | null
mobile: string | null
hire_date: string
status: string
}
interface EmployeeUpdateData {
legal_name?: string
english_name?: string
phone?: string
mobile?: string
hire_date?: string
status?: string
}
export default function EditEmployeePage() {
const { data: session, status } = useSession()
const router = useRouter()
const params = useParams()
const employeeId = params.id as string
const [employee, setEmployee] = useState<Employee | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [formData, setFormData] = useState<EmployeeUpdateData>({})
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/auth/signin')
}
}, [status, router])
useEffect(() => {
if (status === 'authenticated' && employeeId) {
fetchEmployee()
}
}, [status, employeeId])
const fetchEmployee = async () => {
try {
setLoading(true)
setError(null)
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/employees/${employeeId}`
)
if (!response.ok) {
throw new Error('無法載入員工資料')
}
const data: Employee = await response.json()
setEmployee(data)
setFormData({
legal_name: data.legal_name,
english_name: data.english_name || '',
phone: data.phone || '',
mobile: data.mobile || '',
hire_date: data.hire_date.split('T')[0],
status: data.status,
})
} catch (err) {
setError(err instanceof Error ? err.message : '載入失敗')
} finally {
setLoading(false)
}
}
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value } = e.target
setFormData((prev) => ({
...prev,
[name]: value || undefined,
}))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
setError(null)
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/employees/${employeeId}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
}
)
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.detail || '更新員工失敗')
}
// 成功後導向員工詳情頁
router.push(`/employees/${employeeId}`)
} catch (err) {
setError(err instanceof Error ? err.message : '更新失敗')
setSaving(false)
}
}
if (status === 'loading' || loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg">...</div>
</div>
)
}
if (!session || !employee) {
return null
}
return (
<div className="container mx-auto px-4 py-8 max-w-2xl">
<div className="mb-8">
<button
onClick={() => router.back()}
className="text-blue-600 hover:text-blue-800 mb-2"
>
</button>
<h1 className="text-3xl font-bold text-gray-900"></h1>
<p className="mt-2 text-gray-600">
{employee.employee_id} - {employee.legal_name}
</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="bg-white shadow rounded-lg p-6">
<div className="space-y-6">
{/* 員工編號 (唯讀) */}
<div>
<label className="block text-sm font-medium text-gray-700">
</label>
<input
type="text"
value={employee.employee_id}
disabled
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-gray-500"
/>
</div>
{/* 帳號基底 (唯讀) */}
<div>
<label className="block text-sm font-medium text-gray-700">
</label>
<input
type="text"
value={employee.username_base}
disabled
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-gray-500 font-mono"
/>
<p className="mt-1 text-sm text-gray-500">
</p>
</div>
{/* 中文姓名 */}
<div>
<label
htmlFor="legal_name"
className="block text-sm font-medium text-gray-700"
>
<span className="text-red-500">*</span>
</label>
<input
type="text"
id="legal_name"
name="legal_name"
required
value={formData.legal_name}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* 英文姓名 */}
<div>
<label
htmlFor="english_name"
className="block text-sm font-medium text-gray-700"
>
</label>
<input
type="text"
id="english_name"
name="english_name"
value={formData.english_name}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* 電話 */}
<div>
<label
htmlFor="phone"
className="block text-sm font-medium text-gray-700"
>
</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* 手機 */}
<div>
<label
htmlFor="mobile"
className="block text-sm font-medium text-gray-700"
>
</label>
<input
type="tel"
id="mobile"
name="mobile"
value={formData.mobile}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* 到職日 */}
<div>
<label
htmlFor="hire_date"
className="block text-sm font-medium text-gray-700"
>
<span className="text-red-500">*</span>
</label>
<input
type="date"
id="hire_date"
name="hire_date"
required
value={formData.hire_date}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* 狀態 */}
<div>
<label
htmlFor="status"
className="block text-sm font-medium text-gray-700"
>
<span className="text-red-500">*</span>
</label>
<select
id="status"
name="status"
required
value={formData.status}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
>
<option value="active"></option>
<option value="on_leave"></option>
<option value="terminated"></option>
</select>
</div>
</div>
{/* 按鈕區 */}
<div className="mt-8 flex justify-end gap-4">
<button
type="button"
onClick={() => router.back()}
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
disabled={saving}
>
</button>
<button
type="submit"
disabled={saving}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? '儲存中...' : '儲存變更'}
</button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,31 @@
'use client'
import { useEffect } from 'react'
import { useRouter, useParams } from 'next/navigation'
/**
* [已廢棄] 身份管理頁面
* 員工身份 (employee_identities) 已廢棄,請使用部門成員管理
* 自動重導向至員工詳情頁的「部門成員」Tab
*/
export default function IdentitiesRedirectPage() {
const router = useRouter()
const params = useParams()
const employeeId = params.id as string
useEffect(() => {
// 自動重導向到員工詳情頁 (部門成員 Tab 在該頁面)
router.replace(`/employees/${employeeId}`)
}, [employeeId, router])
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<p className="text-gray-600">...</p>
<p className="text-sm text-gray-400 mt-1">
Tab
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,644 @@
'use client'
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter, useParams } from 'next/navigation'
import EmailAccountsTab from '@/components/employees/email-accounts-tab'
import PermissionsTab from '@/components/employees/permissions-tab'
interface DepartmentMembership {
id: number
department_id: number
department_name: string
department_code: string
department_depth: number
effective_email_domain?: string
position?: string
membership_type: string
is_active: boolean
joined_at: string
ended_at?: string
}
interface Employee {
id: number
employee_id: string
username_base: string
legal_name: string
english_name: string | null
phone: string | null
mobile: string | null
hire_date: string
termination_date: string | null
status: string
has_network_drive: boolean
created_at: string
updated_at: string
}
type TabType = 'basic' | 'departments' | 'email' | 'permissions'
interface OffboardResult {
disabled?: boolean
handled?: boolean
created?: boolean
error?: string | null
message?: string
method?: string
}
export default function EmployeeDetailPage() {
const { data: session, status } = useSession()
const router = useRouter()
const params = useParams()
const employeeId = params.id as string
const [employee, setEmployee] = useState<Employee | null>(null)
const [memberships, setMemberships] = useState<DepartmentMembership[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<TabType>('basic')
// 離職處理 Dialog 狀態
const [showOffboardDialog, setShowOffboardDialog] = useState(false)
const [offboardConfirmText, setOffboardConfirmText] = useState('')
const [offboardLoading, setOffboardLoading] = useState(false)
const [offboardEmailHandling, setOffboardEmailHandling] = useState<'forward' | 'disable'>('forward')
const [offboardResults, setOffboardResults] = useState<{
keycloak: OffboardResult
email: OffboardResult
drive: OffboardResult
} | null>(null)
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/auth/signin')
}
}, [status, router])
useEffect(() => {
if (status === 'authenticated' && employeeId) {
fetchEmployee()
fetchMemberships()
}
}, [status, employeeId])
const fetchEmployee = async () => {
try {
setLoading(true)
setError(null)
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/employees/${employeeId}`
)
if (!response.ok) {
throw new Error('無法載入員工資料')
}
const data = await response.json()
setEmployee(data)
} catch (err) {
setError(err instanceof Error ? err.message : '載入失敗')
} finally {
setLoading(false)
}
}
const fetchMemberships = async () => {
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/department-members/?employee_id=${employeeId}`
)
if (response.ok) {
const data = await response.json()
setMemberships(Array.isArray(data) ? data : data.items || [])
}
} catch (err) {
console.error('載入部門成員資料失敗:', err)
}
}
// 執行離職流程
const handleOffboard = async () => {
if (!employee) return
if (offboardConfirmText !== employee.employee_id) return
setOffboardLoading(true)
try {
const searchParams = new URLSearchParams({
disable_keycloak: 'true',
email_handling: offboardEmailHandling,
disable_drive: 'true',
})
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/employees/${employeeId}/offboard?${searchParams.toString()}`,
{ method: 'POST' }
)
if (!response.ok) {
const data = await response.json()
throw new Error(data.detail || '離職流程執行失敗')
}
const data = await response.json()
setOffboardResults(data.results)
setEmployee((prev) => prev ? { ...prev, status: 'terminated' } : null)
} catch (err) {
alert(err instanceof Error ? err.message : '操作失敗')
} finally {
setOffboardLoading(false)
}
}
if (status === 'loading' || loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg">...</div>
</div>
)
}
if (!session) return null
if (error) {
return (
<div className="container mx-auto px-4 py-8">
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
<button
onClick={() => router.back()}
className="mt-4 text-blue-600 hover:text-blue-800"
>
</button>
</div>
)
}
if (!employee) return null
const tabs: { id: TabType; label: string; icon: string }[] = [
{ id: 'basic', label: '基本資料', icon: '👤' },
{ id: 'departments', label: '部門成員', icon: '🏢' },
{ id: 'email', label: '郵件帳號', icon: '📧' },
{ id: 'permissions', label: '系統權限', icon: '🔐' },
]
// 找到主要郵件網域 (取第一個啟用中的成員紀錄的有效網域)
const primaryMembership = memberships.find((m) => m.is_active)
const primaryEmailDomain = primaryMembership?.effective_email_domain
const membershipTypeLabel = (type: string) => {
switch (type) {
case 'permanent': return '正式'
case 'temporary': return '臨時'
case 'project': return '專案'
default: return type
}
}
return (
<div className="container mx-auto px-4 py-8 max-w-6xl">
{/* 頁首 */}
<div className="mb-8 flex items-center justify-between">
<div>
<button
onClick={() => router.back()}
className="text-blue-600 hover:text-blue-800 mb-2"
>
</button>
<h1 className="text-3xl font-bold text-gray-900">
{employee.legal_name}
</h1>
<p className="mt-1 text-gray-600">: {employee.employee_id}</p>
</div>
<div className="flex gap-2">
<button
onClick={() => router.push(`/employees/${employee.id}/edit`)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
</button>
{employee.status === 'active' && (
<button
onClick={() => {
setOffboardConfirmText('')
setOffboardResults(null)
setShowOffboardDialog(true)
}}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
</button>
)}
</div>
</div>
{/* Tab 切換 */}
<div className="mb-6">
<div className="border-b border-gray-200">
<nav className="-mb-px flex gap-8">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`
py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2
${
activeTab === tab.id
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}
`}
>
<span>{tab.icon}</span>
{tab.label}
{tab.id === 'departments' && memberships.length > 0 && (
<span className="ml-1 bg-blue-100 text-blue-700 text-xs px-1.5 py-0.5 rounded-full">
{memberships.filter((m) => m.is_active).length}
</span>
)}
</button>
))}
</nav>
</div>
</div>
{/* 基本資料 Tab */}
{activeTab === 'basic' && (
<div>
<div className="bg-white shadow rounded-lg p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-500"></label>
<p className="mt-1 text-lg text-gray-900">{employee.employee_id}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500"></label>
<span
className={`mt-1 inline-flex px-2 py-1 text-sm font-semibold rounded-full ${
employee.status === 'active'
? 'bg-green-100 text-green-800'
: employee.status === 'inactive'
? 'bg-yellow-100 text-yellow-800'
: 'bg-red-100 text-red-800'
}`}
>
{employee.status === 'active' ? '在職' : employee.status === 'inactive' ? '停用' : '離職'}
</span>
</div>
<div>
<label className="block text-sm font-medium text-gray-500"></label>
<p className="mt-1 text-lg text-gray-900">{employee.legal_name}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500"></label>
<p className="mt-1 text-lg text-gray-900">{employee.english_name || '-'}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500"></label>
<p className="mt-1 text-lg text-gray-900 font-mono">{employee.username_base}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500"></label>
<p className="mt-1 text-lg text-gray-900">
{new Date(employee.hire_date).toLocaleDateString('zh-TW')}
</p>
</div>
{employee.termination_date && (
<div>
<label className="block text-sm font-medium text-gray-500"></label>
<p className="mt-1 text-lg text-gray-900">
{new Date(employee.termination_date).toLocaleDateString('zh-TW')}
</p>
</div>
)}
</div>
</div>
{/* 聯絡資訊 */}
<div className="bg-white shadow rounded-lg p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-500"></label>
<p className="mt-1 text-lg text-gray-900">{employee.phone || '-'}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500"></label>
<p className="mt-1 text-lg text-gray-900">{employee.mobile || '-'}</p>
</div>
</div>
</div>
{/* 系統帳號資訊 */}
<div className="bg-white shadow rounded-lg p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<p className="text-sm font-medium text-gray-500"></p>
<p className="mt-1 text-2xl font-semibold text-gray-900">
{memberships.filter((m) => m.is_active).length}
</p>
<p className="text-xs text-gray-500 mt-1">
{memberships.filter((m) => m.is_active).length > 1 ? '多部門任職' : '單一部門'}
</p>
</div>
<button
onClick={() => setActiveTab('departments')}
className="px-3 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 transition-colors"
>
</button>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<p className="text-sm font-medium text-gray-500">NAS </p>
<p className="mt-1 text-lg font-semibold text-gray-900">
{employee.has_network_drive ? '已建立' : '未建立'}
</p>
</div>
{employee.has_network_drive && (
<span className="text-green-600"></span>
)}
</div>
</div>
</div>
{/* 系統資訊 */}
<div className="bg-gray-50 rounded-lg p-4 text-sm text-gray-600">
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<div>: {new Date(employee.created_at).toLocaleString('zh-TW')}</div>
<div>: {new Date(employee.updated_at).toLocaleString('zh-TW')}</div>
</div>
</div>
</div>
)}
{/* 部門成員 Tab */}
{activeTab === 'departments' && (
<div>
<div className="bg-white shadow rounded-lg p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900"></h2>
<p className="text-sm text-gray-500">
</p>
</div>
{memberships.length === 0 ? (
<div className="text-center py-8">
<p className="text-gray-500"></p>
<p className="text-sm text-gray-400 mt-1">
</p>
</div>
) : (
<div className="space-y-3">
{memberships.map((membership) => (
<div
key={membership.id}
className={`border rounded-lg p-4 ${
membership.is_active
? 'border-blue-200 bg-blue-50'
: 'border-gray-200 bg-gray-50 opacity-60'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900">
{membership.department_name}
</span>
<span className="text-xs text-gray-500 bg-gray-200 px-2 py-0.5 rounded">
{membership.department_code}
</span>
{membership.department_depth === 0 && (
<span className="text-xs text-blue-600 bg-blue-100 px-2 py-0.5 rounded">
</span>
)}
<span
className={`text-xs px-2 py-0.5 rounded ${
membership.is_active
? 'text-green-700 bg-green-100'
: 'text-gray-600 bg-gray-200'
}`}
>
{membership.is_active ? '啟用中' : '已結束'}
</span>
</div>
<div className="flex flex-wrap gap-4 text-sm text-gray-600 mt-2">
{membership.position && (
<div>
<span className="text-gray-400">: </span>
<span>{membership.position}</span>
</div>
)}
<div>
<span className="text-gray-400">: </span>
<span>{membershipTypeLabel(membership.membership_type)}</span>
</div>
{membership.effective_email_domain && (
<div>
<span className="text-gray-400">: </span>
<span className="font-mono text-blue-600">
@{membership.effective_email_domain}
</span>
</div>
)}
</div>
<div className="flex flex-wrap gap-4 text-xs text-gray-500 mt-1">
<div>
: {new Date(membership.joined_at).toLocaleDateString('zh-TW')}
</div>
{membership.ended_at && (
<div>
: {new Date(membership.ended_at).toLocaleDateString('zh-TW')}
</div>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-700">
<p className="font-medium mb-1"></p>
<ul className="space-y-1 text-blue-600">
<li> ( + )</li>
<li> </li>
<li> </li>
<li> 調</li>
</ul>
</div>
</div>
)}
{/* 郵件帳號 Tab */}
{activeTab === 'email' && (
<EmailAccountsTab
employeeId={employee.id}
primaryIdentity={
primaryEmailDomain
? {
email_domain: primaryEmailDomain,
business_unit_name: primaryMembership?.department_name || '',
email_quota_mb: 5120,
}
: undefined
}
/>
)}
{/* 系統權限 Tab */}
{activeTab === 'permissions' && <PermissionsTab employeeId={employee.id} />}
{/* 離職處理 Dialog */}
{showOffboardDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4 p-6">
{offboardResults ? (
<div>
<h2 className="text-xl font-bold text-gray-900 mb-4"></h2>
<div className="space-y-3 mb-6">
<div className={`flex items-start gap-2 p-3 rounded-lg ${offboardResults.keycloak?.disabled ? 'bg-green-50' : 'bg-amber-50'}`}>
<span className="text-lg">{offboardResults.keycloak?.disabled ? '✓' : '⚠'}</span>
<div>
<p className="text-sm font-medium">Keycloak SSO </p>
<p className="text-xs text-gray-600">{offboardResults.keycloak?.message}</p>
{offboardResults.keycloak?.error && (
<p className="text-xs text-red-600">{offboardResults.keycloak.error}</p>
)}
</div>
</div>
<div className={`flex items-start gap-2 p-3 rounded-lg ${offboardResults.email?.handled ? 'bg-green-50' : 'bg-amber-50'}`}>
<span className="text-lg">{offboardResults.email?.handled ? '✓' : '⚠'}</span>
<div>
<p className="text-sm font-medium"> ({offboardResults.email?.method === 'forward' ? '轉發' : '停用'})</p>
<p className="text-xs text-gray-600">{offboardResults.email?.message}</p>
</div>
</div>
<div className={`flex items-start gap-2 p-3 rounded-lg ${offboardResults.drive?.disabled ? 'bg-green-50' : 'bg-amber-50'}`}>
<span className="text-lg">{offboardResults.drive?.disabled ? '✓' : '⚠'}</span>
<div>
<p className="text-sm font-medium"></p>
<p className="text-xs text-gray-600">{offboardResults.drive?.message}</p>
</div>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => {
setShowOffboardDialog(false)
router.push('/employees')
}}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
>
</button>
<button
onClick={() => setShowOffboardDialog(false)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
</button>
</div>
</div>
) : (
<div>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
<span className="text-red-600 text-xl"></span>
</div>
<h2 className="text-xl font-bold text-gray-900"></h2>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<p className="text-sm text-red-700 font-medium"></p>
<ul className="mt-2 text-sm text-red-600 space-y-1">
<li> Keycloak SSO ()</li>
<li> ()</li>
<li> </li>
<li> </li>
</ul>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="email_handling"
value="forward"
checked={offboardEmailHandling === 'forward'}
onChange={() => setOffboardEmailHandling('forward')}
className="w-4 h-4 text-blue-600"
/>
<span className="text-sm text-gray-700"> HR ()</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="email_handling"
value="disable"
checked={offboardEmailHandling === 'disable'}
onChange={() => setOffboardEmailHandling('disable')}
className="w-4 h-4 text-blue-600"
/>
<span className="text-sm text-gray-700"></span>
</label>
</div>
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
<span className="font-mono text-red-600">{employee.employee_id}</span>
</label>
<input
type="text"
value={offboardConfirmText}
onChange={(e) => setOffboardConfirmText(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-red-500 focus:border-red-500 font-mono"
placeholder={employee.employee_id}
/>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowOffboardDialog(false)}
disabled={offboardLoading}
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleOffboard}
disabled={offboardLoading || offboardConfirmText !== employee.employee_id}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{offboardLoading ? '執行中...' : '確認離職'}
</button>
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,53 @@
/**
* Employees 佈局
*/
'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 EmployeesLayout({ 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>
)
}

View File

@@ -0,0 +1,577 @@
'use client'
import { useState, useEffect } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { apiClient } from '@/lib/api-client'
// 第一層部門 (depth=0擁有 email_domain)
interface TopLevelDepartment {
id: number
name: string
code: string
email_domain?: string
effective_email_domain?: string
depth: number
is_active: boolean
}
// 子部門 (depth>=1)
interface SubDepartment {
id: number
name: string
code: string
parent_id: number
depth: number
effective_email_domain?: string
}
interface EmployeeFormData {
username_base: string
legal_name: string
english_name: string
phone: string
mobile: string
hire_date: string
// 組織與職務資訊 (新多層部門架構)
top_department_id: string // 第一層部門 (決定郵件網域)
department_id: string // 指定部門 (選填,可為任何層)
job_title: string
email_quota_mb: string
// 到職自動化
auto_onboard: boolean
create_keycloak: boolean
create_email: boolean
create_drive: boolean
}
interface OnboardResult {
created?: boolean
disabled?: boolean
error?: string | null
message?: string
username?: string
email?: string
user_id?: string
quota_gb?: number
}
export default function NewEmployeePage() {
const { data: session, status } = useSession()
const router = useRouter()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [topDepartments, setTopDepartments] = useState<TopLevelDepartment[]>([])
const [subDepartments, setSubDepartments] = useState<SubDepartment[]>([])
const [loadingSubDepts, setLoadingSubDepts] = useState(false)
const [onboardResults, setOnboardResults] = useState<{
keycloak: OnboardResult
email: OnboardResult
drive: OnboardResult
} | null>(null)
const [formData, setFormData] = useState<EmployeeFormData>({
username_base: '',
legal_name: '',
english_name: '',
phone: '',
mobile: '',
hire_date: new Date().toISOString().split('T')[0],
top_department_id: '',
department_id: '',
job_title: '',
email_quota_mb: '5120',
auto_onboard: true,
create_keycloak: true,
create_email: true,
create_drive: true,
})
// 載入第一層部門列表
useEffect(() => {
if (status === 'authenticated') {
fetchTopDepartments()
}
}, [status])
// 當選擇第一層部門時,載入其子部門
useEffect(() => {
if (formData.top_department_id) {
fetchSubDepartments(parseInt(formData.top_department_id))
} else {
setSubDepartments([])
setFormData((prev) => ({ ...prev, department_id: '' }))
}
}, [formData.top_department_id])
const fetchTopDepartments = async () => {
try {
const data = await apiClient.get<TopLevelDepartment[]>('/departments/?depth=0')
setTopDepartments(data)
} catch (err) {
console.error('載入部門失敗:', err)
}
}
const fetchSubDepartments = async (parentId: number) => {
try {
setLoadingSubDepts(true)
const data = await apiClient.get<SubDepartment[]>(`/departments/?parent_id=${parentId}`)
setSubDepartments(data)
} catch (err) {
console.error('載入子部門失敗:', err)
setSubDepartments([])
} finally {
setLoadingSubDepts(false)
}
}
if (status === 'loading') {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg">...</div>
</div>
)
}
if (status === 'unauthenticated') {
router.push('/auth/signin')
return null
}
const selectedTopDept = topDepartments.find(
(d) => d.id === parseInt(formData.top_department_id)
)
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value, type } = e.target
const checked = (e.target as HTMLInputElement).checked
setFormData((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
// 決定 department_id若有選子部門用子部門否則用第一層部門
const finalDepartmentId = formData.department_id
? parseInt(formData.department_id)
: formData.top_department_id
? parseInt(formData.top_department_id)
: undefined
const employeePayload = {
username_base: formData.username_base,
legal_name: formData.legal_name,
english_name: formData.english_name || undefined,
phone: formData.phone || undefined,
mobile: formData.mobile || undefined,
hire_date: formData.hire_date,
// 新架構department_id 指向任何層部門
department_id: finalDepartmentId,
job_title: formData.job_title,
email_quota_mb: parseInt(formData.email_quota_mb),
}
const newEmployee = await apiClient.post('/employees/', employeePayload) as any
const newEmployeeId = newEmployee.id
// 自動執行到職流程
if (formData.auto_onboard) {
try {
const params = new URLSearchParams({
create_keycloak: String(formData.create_keycloak),
create_email: String(formData.create_email),
create_drive: String(formData.create_drive),
})
const onboardResponse = await apiClient.post(
`/employees/${newEmployeeId}/onboard?${params.toString()}`,
{}
) as any
setOnboardResults(onboardResponse.results)
setTimeout(() => {
router.push(`/employees/${newEmployeeId}`)
}, 3000)
} catch {
router.push(`/employees/${newEmployeeId}`)
}
} else {
router.push(`/employees/${newEmployeeId}`)
}
} catch (err: any) {
const errorMessage = err?.response?.data?.detail || err.message || '新增失敗'
setError(errorMessage)
setLoading(false)
}
}
return (
<div className="container mx-auto px-4 py-8 max-w-2xl">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900"></h1>
<p className="mt-2 text-gray-600"></p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
)}
{/* 到職流程結果顯示 */}
{onboardResults && (
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<h3 className="text-sm font-medium text-green-900 mb-3">
</h3>
<div className="space-y-2">
{formData.create_keycloak && (
<div className={`flex items-center gap-2 text-sm ${onboardResults.keycloak?.created ? 'text-green-700' : 'text-amber-700'}`}>
<span>{onboardResults.keycloak?.created ? '✓' : '⚠'}</span>
<span>Keycloak SSO: {onboardResults.keycloak?.message || (onboardResults.keycloak?.error ? `失敗 - ${onboardResults.keycloak.error}` : '未執行')}</span>
</div>
)}
{formData.create_email && (
<div className={`flex items-center gap-2 text-sm ${onboardResults.email?.created ? 'text-green-700' : 'text-amber-700'}`}>
<span>{onboardResults.email?.created ? '✓' : '⚠'}</span>
<span>: {onboardResults.email?.message || (onboardResults.email?.error ? `失敗 - ${onboardResults.email.error}` : '未執行')}</span>
</div>
)}
{formData.create_drive && (
<div className={`flex items-center gap-2 text-sm ${onboardResults.drive?.created ? 'text-green-700' : 'text-amber-700'}`}>
<span>{onboardResults.drive?.created ? '✓' : '⚠'}</span>
<span>: {onboardResults.drive?.message || (onboardResults.drive?.error ? `失敗 - ${onboardResults.drive.error}` : '未執行')}</span>
</div>
)}
</div>
<p className="mt-3 text-xs text-green-600">3 ...</p>
</div>
)}
<form onSubmit={handleSubmit} className="bg-white shadow rounded-lg p-6">
<div className="space-y-6">
{/* 單一登入帳號 */}
<div>
<label htmlFor="username_base" className="block text-sm font-medium text-gray-700">
(SSO) <span className="text-red-500">*</span>
</label>
<p className="mt-1 text-sm text-gray-500">
SSO
<br />
例如: 輸入 <code className="bg-gray-100 px-1 rounded">porsche.chen</code>
<code className="bg-gray-100 px-1 rounded">porsche.chen@ease.taipei</code>
</p>
<input
type="text"
id="username_base"
name="username_base"
required
value={formData.username_base}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
placeholder="firstname.lastname"
/>
</div>
{/* 中文姓名 */}
<div>
<label htmlFor="legal_name" className="block text-sm font-medium text-gray-700">
<span className="text-red-500">*</span>
</label>
<input
type="text"
id="legal_name"
name="legal_name"
required
value={formData.legal_name}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
placeholder="陳保時"
/>
</div>
{/* 英文姓名 */}
<div>
<label htmlFor="english_name" className="block text-sm font-medium text-gray-700">
</label>
<input
type="text"
id="english_name"
name="english_name"
value={formData.english_name}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
placeholder="Porsche Chen"
/>
</div>
{/* 電話 */}
<div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">
</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
placeholder="02-12345678"
/>
</div>
{/* 手機 */}
<div>
<label htmlFor="mobile" className="block text-sm font-medium text-gray-700">
</label>
<input
type="tel"
id="mobile"
name="mobile"
value={formData.mobile}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
placeholder="0912-345678"
/>
</div>
{/* 到職日 */}
<div>
<label htmlFor="hire_date" className="block text-sm font-medium text-gray-700">
<span className="text-red-500">*</span>
</label>
<input
type="date"
id="hire_date"
name="hire_date"
required
value={formData.hire_date}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
/>
</div>
{/* 分隔線 */}
<div className="border-t border-gray-200 pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">
</h3>
</div>
{/* 第一層部門 (原事業部) */}
<div>
<label htmlFor="top_department_id" className="block text-sm font-medium text-gray-700">
() <span className="text-red-500">*</span>
</label>
<select
id="top_department_id"
name="top_department_id"
required
value={formData.top_department_id}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
>
<option value=""></option>
{topDepartments.map((dept) => (
<option key={dept.id} value={dept.id}>
{dept.name}
{dept.email_domain ? ` (@${dept.email_domain})` : ''}
</option>
))}
</select>
<p className="mt-1 text-sm text-gray-500">
{selectedTopDept?.email_domain && (
<span className="ml-1 font-mono text-blue-600">
@{selectedTopDept.email_domain}
</span>
)}
</p>
</div>
{/* 子部門 (選填) */}
<div>
<label htmlFor="department_id" className="block text-sm font-medium text-gray-700">
()
</label>
<select
id="department_id"
name="department_id"
value={formData.department_id}
onChange={handleChange}
disabled={!formData.top_department_id || loadingSubDepts}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900 disabled:bg-gray-100 disabled:cursor-not-allowed disabled:text-gray-500"
>
<option value="">
{!formData.top_department_id
? '請先選擇第一層部門'
: loadingSubDepts
? '載入中...'
: subDepartments.length > 0
? '請選擇子部門 (選填)'
: '此部門尚無子部門'}
</option>
{subDepartments.map((dept) => (
<option key={dept.id} value={dept.id}>
{dept.name} ({dept.code})
</option>
))}
</select>
<p className="mt-1 text-sm text-gray-500">
</p>
</div>
{/* 職稱 */}
<div>
<label htmlFor="job_title" className="block text-sm font-medium text-gray-700">
<span className="text-red-500">*</span>
</label>
<input
type="text"
id="job_title"
name="job_title"
required
value={formData.job_title}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
placeholder="例如: 軟體工程師、技術總監"
/>
</div>
{/* 郵件配額 */}
<div>
<label htmlFor="email_quota_mb" className="block text-sm font-medium text-gray-700">
(MB) <span className="text-red-500">*</span>
</label>
<input
type="number"
id="email_quota_mb"
name="email_quota_mb"
required
min="1024"
max="51200"
step="1024"
value={formData.email_quota_mb}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-gray-900"
/>
<p className="mt-1 text-sm text-gray-500">
5120 MB (5 GB) 1024 MB 51200 MB
</p>
</div>
</div>
{/* 到職自動化流程 */}
<div className="border-t border-gray-200 pt-6 mt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">
</h3>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
name="auto_onboard"
checked={formData.auto_onboard}
onChange={handleChange}
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
/>
<span className="text-sm font-medium text-gray-700"></span>
</label>
</div>
{formData.auto_onboard && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-3">
<p className="text-sm text-blue-700 font-medium"></p>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
name="create_keycloak"
checked={formData.create_keycloak}
onChange={handleChange}
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
/>
<div>
<span className="text-sm font-medium text-gray-700">Keycloak SSO </span>
<p className="text-xs text-gray-500"> SSO ()</p>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
name="create_email"
checked={formData.create_email}
onChange={handleChange}
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
/>
<div>
<span className="text-sm font-medium text-gray-700"></span>
<p className="text-xs text-gray-500"></p>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
name="create_drive"
checked={formData.create_drive}
onChange={handleChange}
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
/>
<div>
<span className="text-sm font-medium text-gray-700"></span>
<p className="text-xs text-gray-500"> Nextcloud (Drive Service )</p>
</div>
</label>
</div>
)}
</div>
{/* 按鈕區 */}
<div className="mt-8 flex justify-end gap-4">
<button
type="button"
onClick={() => router.back()}
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
disabled={loading}
>
</button>
<button
type="submit"
disabled={loading}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '建立中...' : '建立員工'}
</button>
</div>
</form>
{/* 提示訊息 */}
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h3 className="text-sm font-medium text-blue-900 mb-2">
💡
</h3>
<ul className="text-sm text-blue-700 space-y-1">
<li> (EMP001, EMP002...)</li>
<li> </li>
<li> SSO </li>
<li> </li>
<li> :</li>
<li className="ml-4">- Keycloak SSO ()</li>
<li className="ml-4">- </li>
<li className="ml-4">- (Drive Service)</li>
<li> </li>
</ul>
</div>
</div>
)
}

View File

@@ -0,0 +1,304 @@
'use client'
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
interface Employee {
id: number
employee_id: string
username_base: string
legal_name: string
english_name: string | null
phone: string | null
mobile: string | null
hire_date: string
termination_date: string | null
status: string
identities_count?: number
has_network_drive?: boolean
// Phase 2.3: 主要身份資訊
primary_business_unit?: string | null
primary_department?: string | null
primary_job_title?: string | null
}
interface EmployeeListResponse {
total: number
page: number
page_size: number
total_pages: number
items: Employee[]
}
export default function EmployeesPage() {
const { data: session, status } = useSession()
const router = useRouter()
const [employees, setEmployees] = useState<Employee[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [pagination, setPagination] = useState({
total: 0,
page: 1,
page_size: 20,
total_pages: 0,
})
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('')
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/auth/signin')
}
}, [status, router])
useEffect(() => {
if (status === 'authenticated') {
fetchEmployees()
}
}, [status, pagination.page, search, statusFilter])
const fetchEmployees = async () => {
try {
setLoading(true)
setError(null)
const params = new URLSearchParams({
page: pagination.page.toString(),
page_size: pagination.page_size.toString(),
})
if (search) params.append('search', search)
if (statusFilter) params.append('status_filter', statusFilter)
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/employees/?${params.toString()}`
)
if (!response.ok) {
throw new Error('無法載入員工列表')
}
const data: EmployeeListResponse = await response.json()
setEmployees(data.items)
setPagination({
total: data.total,
page: data.page,
page_size: data.page_size,
total_pages: data.total_pages,
})
} catch (err) {
setError(err instanceof Error ? err.message : '載入失敗')
} finally {
setLoading(false)
}
}
const handleSearch = (value: string) => {
setSearch(value)
setPagination({ ...pagination, page: 1 })
}
const handleStatusFilter = (value: string) => {
setStatusFilter(value)
setPagination({ ...pagination, page: 1 })
}
const handlePageChange = (newPage: number) => {
setPagination({ ...pagination, page: newPage })
}
if (status === 'loading' || loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg">...</div>
</div>
)
}
if (!session) {
return null
}
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900"></h1>
<p className="mt-2 text-gray-600"></p>
</div>
{/* 搜尋與篩選 */}
<div className="mb-6 flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<input
type="text"
placeholder="搜尋姓名、工號或帳號..."
value={search}
onChange={(e) => handleSearch(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<select
value={statusFilter}
onChange={(e) => handleStatusFilter(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value=""></option>
<option value="active"></option>
<option value="on_leave"></option>
<option value="terminated"></option>
</select>
</div>
<button
onClick={() => router.push('/employees/new')}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
+
</button>
</div>
{/* 錯誤訊息 */}
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
)}
{/* 員工列表 */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
/
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{employees.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-gray-500">
</td>
</tr>
) : (
employees.map((employee) => (
<tr key={employee.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{employee.employee_id}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{employee.legal_name}
</div>
{employee.english_name && (
<div className="text-sm text-gray-500">
{employee.english_name}
</div>
)}
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900">
{employee.primary_business_unit || '-'}
</div>
{employee.primary_department && (
<div className="text-xs text-gray-500">
{employee.primary_department}
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{employee.primary_job_title || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(employee.hire_date).toLocaleDateString('zh-TW')}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
employee.status === 'active'
? 'bg-green-100 text-green-800'
: employee.status === 'on_leave'
? 'bg-yellow-100 text-yellow-800'
: 'bg-red-100 text-red-800'
}`}
>
{employee.status === 'active'
? '在職'
: employee.status === 'on_leave'
? '留停'
: '離職'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => router.push(`/employees/${employee.id}`)}
className="text-blue-600 hover:text-blue-900 mr-4"
>
</button>
<button
onClick={() => router.push(`/employees/${employee.id}/edit`)}
className="text-indigo-600 hover:text-indigo-900"
>
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* 分頁 */}
{pagination.total_pages > 1 && (
<div className="mt-6 flex items-center justify-between">
<div className="text-sm text-gray-700">
{(pagination.page - 1) * pagination.page_size + 1} -{' '}
{Math.min(pagination.page * pagination.page_size, pagination.total)}
{pagination.total}
</div>
<div className="flex gap-2">
<button
onClick={() => handlePageChange(pagination.page - 1)}
disabled={pagination.page === 1}
className="px-4 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
</button>
<span className="px-4 py-2 border border-gray-300 rounded-lg bg-white">
{pagination.page} / {pagination.total_pages}
</span>
<button
onClick={() => handlePageChange(pagination.page + 1)}
disabled={pagination.page === pagination.total_pages}
className="px-4 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
</button>
</div>
</div>
)}
</div>
)
}

BIN
frontend/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

55
frontend/app/globals.css Normal file
View File

@@ -0,0 +1,55 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
/* Modal Animations */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.2s ease-out;
}
.animate-slideIn {
animation: slideIn 0.3s ease-out;
}

View File

@@ -0,0 +1,802 @@
/**
* 完成初始化頁面
*
* 流程:
* 1. 填寫公司基本資訊
* 2. 設定郵件網域
* 3. 設定管理員帳號
* 4. 確認並執行初始化
*/
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
interface CompanyInfo {
company_name: string
company_name_en: string
tenant_code: string
tenant_prefix: string
tax_id: string
tel: string
add: string
}
interface MailDomainInfo {
domain_set: 1 | 2
domain: string
}
interface AdminInfo {
admin_legal_name: string
admin_english_name: string
admin_email: string
admin_phone: string
password_method: 'auto' | 'manual'
manual_password?: string
}
export default function CompleteInitialization() {
const router = useRouter()
const [step, setStep] = useState<'checking' | 'company' | 'maildomain' | 'admin' | 'confirm' | 'executing'>('checking')
const [sessionId, setSessionId] = useState<number | null>(null)
const [checkingDb, setCheckingDb] = useState(true)
const [dbReady, setDbReady] = useState(false)
const [companyInfo, setCompanyInfo] = useState<CompanyInfo>({
company_name: '',
company_name_en: '',
tenant_code: '',
tenant_prefix: '',
tax_id: '',
tel: '',
add: '',
})
const [mailDomainInfo, setMailDomainInfo] = useState<MailDomainInfo>({
domain_set: 2,
domain: '',
})
const [adminInfo, setAdminInfo] = useState<AdminInfo>({
admin_legal_name: '',
admin_english_name: '',
admin_email: '',
admin_phone: '',
password_method: 'auto',
})
const [generatedPassword, setGeneratedPassword] = useState<string>('')
const [executing, setExecuting] = useState(false)
// 載入 Keycloak Realm 名稱作為租戶代碼預設值
useEffect(() => {
const loadKeycloakRealm = async () => {
try {
const response = await fetch('http://10.1.0.245:10181/api/v1/installation/get-config/keycloak')
if (response.ok) {
const data = await response.json()
if (data.configured && data.config && data.config.realm) {
setCompanyInfo(prev => ({
...prev,
tenant_code: data.config.realm.toLowerCase(), // ⚠️ 必須小寫,與 Keycloak Realm 一致
}))
}
}
setCheckingDb(false)
setDbReady(true)
setStep('company')
} catch (error) {
console.error('[Installation] Failed to load Keycloak config:', error)
setCheckingDb(false)
setDbReady(true)
setStep('company')
}
}
loadKeycloakRealm()
}, [])
// Step 1: 建立會話並儲存公司資訊
const handleSaveCompany = async () => {
try {
// 建立會話
if (!sessionId) {
const sessionResponse = await fetch('http://10.1.0.245:10181/api/v1/installation/sessions', {
method: 'POST',
})
if (!sessionResponse.ok) {
throw new Error('建立會話失敗')
}
const sessionData = await sessionResponse.json()
setSessionId(sessionData.session_id)
}
setStep('maildomain')
} catch (error) {
alert(`錯誤: ${error instanceof Error ? error.message : '未知錯誤'}`)
}
}
// Step 2: 儲存郵件網域設定
const handleSaveMailDomain = async () => {
try {
if (!sessionId) {
throw new Error('會話 ID 不存在')
}
// 合併公司資訊 + 郵件網域資訊
const tenantData = {
...companyInfo,
...mailDomainInfo,
}
const response = await fetch(`http://10.1.0.245:10181/api/v1/installation/sessions/${sessionId}/tenant-info`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tenantData),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))
throw new Error(`儲存租戶資訊失敗: ${errorData.detail || response.statusText}`)
}
setStep('admin')
} catch (error) {
console.error('[MailDomain] Save failed:', error)
alert(`錯誤: ${error instanceof Error ? error.message : '未知錯誤'}`)
}
}
// Step 3: 設定管理員帳號
const handleSaveAdmin = async () => {
try {
if (!sessionId) {
throw new Error('會話 ID 不存在')
}
const response = await fetch(`http://10.1.0.245:10181/api/v1/installation/sessions/${sessionId}/admin-setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(adminInfo),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.detail || '設定管理員失敗')
}
if (data.initial_password) {
setGeneratedPassword(data.initial_password)
}
setStep('confirm')
} catch (error) {
alert(`錯誤: ${error instanceof Error ? error.message : '未知錯誤'}`)
}
}
// Step 4: 執行初始化
const handleExecute = async () => {
try {
if (!sessionId) {
throw new Error('會話 ID 不存在')
}
setStep('executing')
setExecuting(true)
const response = await fetch(`http://10.1.0.245:10181/api/v1/installation/sessions/${sessionId}/execute`, {
method: 'POST',
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.detail || '初始化失敗')
}
// 從回應中取得臨時密碼
const tempPassword = data.result?.credentials?.plain_password || '(密碼已清除,請聯絡管理員)'
const username = adminInfo.admin_english_name
// 顯示完成訊息和臨時密碼
alert(`初始化完成!
請使用以下資訊登入 SSO 系統:
帳號: ${username}
臨時密碼: ${tempPassword}
⚠️ 重要提醒:
1. 請立即記下或截圖這個密碼
2. 首次登入後系統會要求您變更密碼
3. 此密碼僅顯示一次,關閉後將無法再次查看
點擊「確定」後將跳轉至登入頁面。`)
// 跳轉到登入頁面
window.location.href = '/auth/signin'
} catch (error) {
alert(`錯誤: ${error instanceof Error ? error.message : '未知錯誤'}`)
setExecuting(false)
}
}
return (
<div className="min-h-screen bg-gray-950">
<div className="container mx-auto px-4 py-8">
<div className="max-w-3xl mx-auto">
{/* Progress Bar */}
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-300"></span>
<span className="text-sm font-medium text-gray-300">
{step === 'company' ? '1' : step === 'maildomain' ? '2' : step === 'admin' ? '3' : step === 'confirm' ? '4' : '5'} / 4
</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div
className="bg-gradient-to-r from-green-500 to-emerald-600 h-2 rounded-full transition-all"
style={{
width:
step === 'company' ? '25%' :
step === 'maildomain' ? '50%' :
step === 'admin' ? '75%' : '100%'
}}
></div>
</div>
</div>
{/* Main Card */}
<div className="bg-gray-800 rounded-xl shadow-xl p-8 border border-gray-700">
<div className="flex items-center mb-6">
<div className="w-12 h-12 bg-green-900/30 rounded-full flex items-center justify-center mr-4 border border-green-800">
<span className="text-2xl"></span>
</div>
<div>
<h1 className="text-2xl font-bold text-gray-100"></h1>
<p className="text-gray-400">
{step === 'company' && '填寫公司基本資訊'}
{step === 'maildomain' && '設定郵件網域'}
{step === 'admin' && '設定系統管理員'}
{step === 'confirm' && '確認並執行'}
{step === 'executing' && '正在初始化系統...'}
</p>
</div>
</div>
{/* Step 1: 公司資訊 */}
{step === 'company' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<span className="text-green-400">*</span>
</label>
<input
type="text"
value={companyInfo.company_name}
onChange={(e) => setCompanyInfo({ ...companyInfo, company_name: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="例如: 匠耘股份有限公司"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
</label>
<input
type="text"
value={companyInfo.company_name_en}
onChange={(e) => setCompanyInfo({ ...companyInfo, company_name_en: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="例如: Porsche World Co., Ltd."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
</label>
<input
type="text"
value={companyInfo.tax_id}
onChange={(e) => setCompanyInfo({ ...companyInfo, tax_id: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="例如: 82871784"
maxLength={8}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
</label>
<input
type="text"
value={companyInfo.tel}
onChange={(e) => setCompanyInfo({ ...companyInfo, tel: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="例如: 02-26262026"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
</label>
<textarea
value={companyInfo.add}
onChange={(e) => setCompanyInfo({ ...companyInfo, add: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="例如: 新北市淡水區北新路197號7樓"
rows={2}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Keycloak Realm <span className="text-green-400">*</span>
</label>
<input
type="text"
value={companyInfo.tenant_code}
readOnly
className="w-full px-4 py-2 bg-gray-600 border border-gray-500 rounded-lg text-gray-300 font-mono cursor-not-allowed"
placeholder="自動載入 Keycloak Realm..."
/>
<p className="text-xs text-gray-400 mt-1">🔒 Keycloak Realm </p>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<span className="text-green-400">*</span>
</label>
<input
type="text"
value={companyInfo.tenant_prefix}
onChange={(e) => setCompanyInfo({ ...companyInfo, tenant_prefix: e.target.value.toUpperCase() })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent font-mono"
placeholder="例如: PW"
maxLength={10}
/>
<p className="text-xs text-gray-400 mt-1">{companyInfo.tenant_prefix || 'XX'}001</p>
</div>
</div>
<div className="bg-blue-900/20 border border-blue-700 rounded-lg p-4">
<p className="text-sm text-blue-300">
<strong>💡 </strong>
</p>
<ul className="text-sm text-blue-300 mt-2 space-y-1 ml-4 list-disc">
<li>code= Keycloak Realm SSO </li>
<li> <strong></strong> Keycloak Realm </li>
<li>prefixPW PW001</li>
<li></li>
</ul>
</div>
<div className="flex gap-4 mt-8">
<button
onClick={() => router.back()}
className="px-6 py-2 border border-gray-600 rounded-lg text-gray-300 hover:bg-gray-700 transition-colors"
>
</button>
<button
onClick={handleSaveCompany}
disabled={!companyInfo.company_name || !companyInfo.tenant_code || !companyInfo.tenant_prefix}
className="flex-1 bg-green-600 text-white py-2 px-6 rounded-lg hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors font-semibold"
>
</button>
</div>
</div>
)}
{/* Step 2: 郵件網域設定 */}
{step === 'maildomain' && (
<div className="space-y-4">
<div className="bg-yellow-900/20 border border-yellow-700 rounded-lg p-4 mb-6">
<p className="text-sm text-yellow-300">
<strong> DNS </strong>
</p>
<p className="text-sm text-yellow-300 mt-2">
DNS MX
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<span className="text-green-400">*</span>
</label>
<div className="space-y-2">
<label className="flex items-start p-4 bg-gray-700/50 border border-gray-600 rounded-lg cursor-pointer hover:bg-gray-700">
<input
type="radio"
name="domainSet"
checked={mailDomainInfo.domain_set === 1}
onChange={() => setMailDomainInfo({ ...mailDomainInfo, domain_set: 1 })}
className="mr-3 mt-1"
/>
<div>
<span className="text-gray-200 font-medium"></span>
<p className="text-sm text-gray-400 mt-1">
使user@porscheworld.tw
</p>
</div>
</label>
<label className="flex items-start p-4 bg-gray-700/50 border border-gray-600 rounded-lg cursor-pointer hover:bg-gray-700">
<input
type="radio"
name="domainSet"
checked={mailDomainInfo.domain_set === 2}
onChange={() => setMailDomainInfo({ ...mailDomainInfo, domain_set: 2 })}
className="mr-3 mt-1"
/>
<div>
<span className="text-gray-200 font-medium"></span>
<p className="text-sm text-gray-400 mt-1">
使hr@ease.taipei, mis@lab.taipei
</p>
</div>
</label>
</div>
</div>
{/* 組織網域模式:所有員工使用統一網域 */}
{mailDomainInfo.domain_set === 1 && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<span className="text-green-400">*</span>
</label>
<input
type="text"
value={mailDomainInfo.domain}
onChange={(e) => setMailDomainInfo({ ...mailDomainInfo, domain: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="例如: porscheworld.tw"
/>
<p className="text-xs text-gray-400 mt-2">
DNS <br/>
MX : {mailDomainInfo.domain || 'yourdomain.tw'} mail.{mailDomainInfo.domain || 'yourdomain.tw'} ( 10)
</p>
</div>
)}
{/* 部門網域模式:需要輸入預設網域給系統管理員使用 */}
{mailDomainInfo.domain_set === 2 && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
使 <span className="text-green-400">*</span>
</label>
<input
type="text"
value={mailDomainInfo.domain}
onChange={(e) => setMailDomainInfo({ ...mailDomainInfo, domain: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="例如: porscheworld.tw"
/>
<p className="text-xs text-gray-400 mt-2">
</p>
</div>
)}
{mailDomainInfo.domain_set === 2 && (
<div className="bg-blue-900/20 border border-blue-700 rounded-lg p-4">
<p className="text-sm text-blue-300">
<strong>💡 </strong>
</p>
<p className="text-sm text-blue-300 mt-2">
<br/>
ease.taipei ()lab.taipei ()porscheworld.tw ()
</p>
<p className="text-sm text-blue-300 mt-2">
</p>
</div>
)}
<div className="flex gap-4 mt-8">
<button
onClick={() => setStep('company')}
className="px-6 py-2 border border-gray-600 rounded-lg text-gray-300 hover:bg-gray-700 transition-colors"
>
</button>
<button
onClick={handleSaveMailDomain}
disabled={!mailDomainInfo.domain}
className="flex-1 bg-green-600 text-white py-2 px-6 rounded-lg hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors font-semibold"
>
</button>
</div>
</div>
)}
{/* Step 3: 管理員資訊 */}
{step === 'admin' && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<span className="text-green-400">*</span>
</label>
<input
type="text"
value={adminInfo.admin_legal_name}
onChange={(e) => setAdminInfo({ ...adminInfo, admin_legal_name: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="例如: 陳柏旭"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
</label>
<input
type="text"
value={adminInfo.admin_english_name}
onChange={(e) => setAdminInfo({ ...adminInfo, admin_english_name: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="例如: Porsche Chen"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Email <span className="text-green-400">*</span>
</label>
<input
type="email"
value={adminInfo.admin_email}
onChange={(e) => setAdminInfo({ ...adminInfo, admin_email: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="例如: admin@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
</label>
<input
type="tel"
value={adminInfo.admin_phone}
onChange={(e) => setAdminInfo({ ...adminInfo, admin_phone: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="例如: 0912345678"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
</label>
<div className="space-y-2">
<label className="flex items-center p-3 bg-gray-700/50 border border-gray-600 rounded-lg cursor-pointer hover:bg-gray-700">
<input
type="radio"
name="passwordMethod"
checked={adminInfo.password_method === 'auto'}
onChange={() => setAdminInfo({ ...adminInfo, password_method: 'auto', manual_password: undefined })}
className="mr-3"
/>
<span className="text-gray-200"></span>
</label>
<label className="flex items-center p-3 bg-gray-700/50 border border-gray-600 rounded-lg cursor-pointer hover:bg-gray-700">
<input
type="radio"
name="passwordMethod"
checked={adminInfo.password_method === 'manual'}
onChange={() => setAdminInfo({ ...adminInfo, password_method: 'manual' })}
className="mr-3"
/>
<span className="text-gray-200"></span>
</label>
</div>
</div>
{adminInfo.password_method === 'manual' && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<span className="text-green-400">*</span>
</label>
<input
type="password"
value={adminInfo.manual_password || ''}
onChange={(e) => setAdminInfo({ ...adminInfo, manual_password: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="請輸入密碼"
/>
</div>
)}
<div className="bg-blue-900/20 border border-blue-700 rounded-lg p-4 mt-6">
<p className="text-sm text-blue-300">
<strong>💡 </strong>
</p>
<ul className="text-sm text-blue-300 mt-2 space-y-1 ml-4 list-disc">
<li></li>
<li></li>
<li>調 MIS </li>
</ul>
</div>
<div className="flex gap-4 mt-8">
<button
onClick={() => setStep('maildomain')}
className="px-6 py-2 border border-gray-600 rounded-lg text-gray-300 hover:bg-gray-700 transition-colors"
>
</button>
<button
onClick={handleSaveAdmin}
disabled={!adminInfo.admin_legal_name || !adminInfo.admin_email || (adminInfo.password_method === 'manual' && !adminInfo.manual_password)}
className="flex-1 bg-green-600 text-white py-2 px-6 rounded-lg hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors font-semibold"
>
</button>
</div>
</div>
)}
{/* Step 4: 確認資訊 */}
{step === 'confirm' && (
<div className="space-y-6">
<div className="bg-blue-900/20 border border-blue-700 rounded-lg p-4">
<p className="text-sm text-blue-300">
<strong> </strong>
</p>
<ul className="text-sm text-blue-300 mt-2 space-y-1 ml-4 list-disc">
<li></li>
<li>INIT</li>
<li>{companyInfo.tenant_prefix}001</li>
<li>Keycloak SSO </li>
<li> ({adminInfo.admin_email})</li>
</ul>
<p className="text-sm text-blue-300 mt-2">
</p>
</div>
<div className="space-y-4">
<div className="bg-gray-700/50 rounded-lg p-4">
<h3 className="text-lg font-semibold text-green-300 mb-3"></h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="text-gray-200">{companyInfo.company_name}</span>
</div>
{companyInfo.company_name_en && (
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="text-gray-200">{companyInfo.company_name_en}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="text-gray-200 font-mono">{companyInfo.tenant_code}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="text-gray-200 font-mono">{companyInfo.tenant_prefix}</span>
</div>
{companyInfo.tax_id && (
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="text-gray-200">{companyInfo.tax_id}</span>
</div>
)}
</div>
</div>
<div className="bg-gray-700/50 rounded-lg p-4">
<h3 className="text-lg font-semibold text-green-300 mb-3"></h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="text-gray-200">
{mailDomainInfo.domain_set === 1 ? '組織網域' : '部門網域'}
</span>
</div>
{mailDomainInfo.domain_set === 1 && mailDomainInfo.domain && (
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="text-gray-200 font-mono">{mailDomainInfo.domain}</span>
</div>
)}
</div>
</div>
<div className="bg-gray-700/50 rounded-lg p-4">
<h3 className="text-lg font-semibold text-green-300 mb-3"></h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="text-gray-200">{adminInfo.admin_legal_name}</span>
</div>
{adminInfo.admin_english_name && (
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="text-gray-200">{adminInfo.admin_english_name}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-gray-400">Email</span>
<span className="text-gray-200">{adminInfo.admin_email}</span>
</div>
{adminInfo.admin_phone && (
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="text-gray-200">{adminInfo.admin_phone}</span>
</div>
)}
</div>
</div>
{generatedPassword && (
<div className="bg-yellow-900/20 border border-yellow-700 rounded-lg p-4">
<h3 className="text-lg font-semibold text-yellow-300 mb-3"> </h3>
<div className="bg-gray-900 rounded p-3 font-mono text-yellow-200 text-center text-lg">
{generatedPassword}
</div>
<p className="text-xs text-yellow-400 mt-2">
</p>
</div>
)}
</div>
<div className="bg-yellow-900/20 border border-yellow-700 rounded-lg p-4">
<p className="text-sm text-yellow-300">
<strong>📋 </strong>
</p>
<ol className="text-sm text-yellow-300 mt-2 space-y-1 ml-4 list-decimal">
<li>使</li>
<li></li>
<li></li>
<li>調</li>
</ol>
</div>
<div className="flex gap-4 mt-8">
<button
onClick={() => setStep('admin')}
className="px-6 py-2 border border-gray-600 rounded-lg text-gray-300 hover:bg-gray-700 transition-colors"
>
</button>
<button
onClick={handleExecute}
className="flex-1 bg-gradient-to-r from-green-600 to-emerald-600 text-white py-3 px-6 rounded-lg font-semibold text-lg hover:from-green-700 hover:to-emerald-700 transition-all transform hover:scale-105 shadow-lg"
>
🚀
</button>
</div>
</div>
)}
{/* Step 4: 執行中 */}
{step === 'executing' && (
<div className="text-center py-12">
<div className="w-20 h-20 border-4 border-green-500 border-t-transparent rounded-full animate-spin mx-auto mb-6"></div>
<h3 className="text-xl font-semibold text-green-300 mb-2"></h3>
<p className="text-gray-400">...</p>
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,481 @@
/**
* HR Portal 系統初始化引導頁面
*
* 功能:
* 1. 檢查系統初始化狀態三階段Initialization/Operational/Transition
* 2. 引導用戶完成環境配置
* 3. 顯示當前階段與配置進度
*/
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
interface SystemStatus {
current_phase: 'initialization' | 'operational' | 'transition'
is_initialized: boolean
initialization_completed: boolean
configured_count: number
configured_categories: string[]
missing_categories: string[]
is_locked?: boolean
next_action: string
message: string
// Operational 階段欄位
last_health_check_at?: string
health_check_status?: string
// Transition 階段欄位
env_db_consistent?: boolean
inconsistencies?: string
}
interface ConfigDetail {
redis?: { host: string; port: string; db: string }
database?: { host: string; port: string; name: string; user: string }
keycloak?: { url: string; realm: string; admin_username: string }
}
export default function InstallationPage() {
const router = useRouter()
const [status, setStatus] = useState<SystemStatus | null>(null)
const [configDetails, setConfigDetails] = useState<ConfigDetail>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
checkSystemStatus()
}, [])
const checkSystemStatus = async () => {
try {
setLoading(true)
setError(null)
const response = await fetch('http://10.1.0.245:10181/api/v1/installation/check-status')
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data: SystemStatus = await response.json()
setStatus(data)
// 載入已完成配置的詳細資訊
if (data.configured_categories.length > 0) {
await loadConfigDetails(data.configured_categories)
}
// 如果已初始化,導向健康檢查頁面
if (data.is_initialized) {
router.push('/installation/health-check')
}
} catch (err) {
console.error('[Installation] Failed to check status:', err)
setError(err instanceof Error ? err.message : '無法連接到後端 API')
} finally {
setLoading(false)
}
}
const loadConfigDetails = async (categories: string[]) => {
const details: ConfigDetail = {}
for (const category of categories) {
try {
const response = await fetch(`http://10.1.0.245:10181/api/v1/installation/get-config/${category}`)
if (response.ok) {
const data = await response.json()
console.log(`[Installation] Loaded ${category} config:`, data)
if (data.configured && data.config) {
details[category as keyof ConfigDetail] = data.config
console.log(`[Installation] ${category} details:`, data.config)
}
}
} catch (error) {
console.error(`[Installation] Failed to load ${category} config:`, error)
}
}
console.log('[Installation] All config details:', details)
setConfigDetails(details)
}
const startInstallation = () => {
// 導向下一個未完成的設定階段
if (!status) return
if (!status.configured_categories.includes('redis')) {
router.push('/installation/phase1-redis')
} else if (!status.configured_categories.includes('database')) {
router.push('/installation/phase2-database')
} else if (!status.configured_categories.includes('keycloak')) {
router.push('/installation/phase3-keycloak')
}
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-950">
<div className="text-center">
<div className="w-16 h-16 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<h1 className="text-2xl font-bold text-gray-100 mb-2">HR Portal </h1>
<p className="text-gray-400">...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-950">
<div className="bg-gray-800 rounded-lg shadow-xl p-8 max-w-md border border-gray-700">
<div className="text-center mb-6">
<div className="w-16 h-16 bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-4 border border-red-800">
<svg className="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h2 className="text-2xl font-bold text-gray-100 mb-2"></h2>
<p className="text-gray-300 mb-4">{error}</p>
<div className="bg-yellow-900/20 border border-yellow-700 rounded-lg p-4 text-left text-sm">
<p className="font-semibold text-yellow-400 mb-2"></p>
<ul className="list-disc list-inside text-yellow-300 space-y-1">
<li> (Port 10181)</li>
<li></li>
<li></li>
</ul>
</div>
</div>
<button
onClick={checkSystemStatus}
className="w-full bg-indigo-600 text-white py-2 px-4 rounded-lg hover:bg-indigo-700 transition-colors"
>
</button>
</div>
</div>
)
}
// 階段顏色與圖示(深色主題)
const getPhaseConfig = (phase: string) => {
switch (phase) {
case 'initialization':
return {
color: 'from-blue-500 to-indigo-600',
bgColor: 'bg-blue-900/20',
borderColor: 'border-blue-700',
textColor: 'text-blue-300',
icon: '⚙️',
title: 'Initialization 階段',
description: '系統初始化中,正在設定環境配置'
}
case 'operational':
return {
color: 'from-green-500 to-emerald-600',
bgColor: 'bg-green-900/20',
borderColor: 'border-green-700',
textColor: 'text-green-300',
icon: '✅',
title: 'Operational 階段',
description: '系統正常運作中,可進行健康檢查'
}
case 'transition':
return {
color: 'from-orange-500 to-amber-600',
bgColor: 'bg-orange-900/20',
borderColor: 'border-orange-700',
textColor: 'text-orange-300',
icon: '🔄',
title: 'Transition 階段',
description: '系統移轉中,正在檢查環境一致性'
}
default:
return {
color: 'from-gray-500 to-gray-600',
bgColor: 'bg-gray-800',
borderColor: 'border-gray-700',
textColor: 'text-gray-300',
icon: '❓',
title: '未知階段',
description: '系統狀態未知'
}
}
}
const phaseConfig = status ? getPhaseConfig(status.current_phase) : null
return (
<div className="min-h-screen bg-gray-950">
<div className="container mx-auto px-4 py-16">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-100 mb-4">
HR Portal
</h1>
<p className="text-xl text-gray-400">
</p>
</div>
{/* Phase Status Card */}
{status && phaseConfig && (
<div className="bg-gray-800 rounded-xl shadow-2xl p-8 mb-8 border border-gray-700">
<div className="flex items-center justify-center mb-6">
<div className={`w-20 h-20 bg-gradient-to-br ${phaseConfig.color} rounded-full flex items-center justify-center text-4xl`}>
{phaseConfig.icon}
</div>
</div>
<h2 className="text-2xl font-bold text-center text-gray-100 mb-2">
{phaseConfig.title}
</h2>
<p className="text-center text-gray-400 mb-8">
{phaseConfig.description}
</p>
{/* Message from Backend */}
<div className={`${phaseConfig.bgColor} ${phaseConfig.borderColor} border rounded-lg p-4 mb-8`}>
<p className={`${phaseConfig.textColor} text-center font-medium`}>
{status.message}
</p>
</div>
{/* Configuration Progress */}
{status.current_phase === 'initialization' && (
<>
<div className="mb-6">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-300"></span>
<span className="text-sm text-gray-400">
{status.configured_count} / 3
</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div
className="bg-gradient-to-r from-indigo-500 to-purple-600 h-2 rounded-full transition-all"
style={{ width: `${(status.configured_count / 3) * 100}%` }}
></div>
</div>
</div>
<div className="space-y-3 mb-8">
{[
{ key: 'redis', name: 'Redis 快取設定', icon: '🔴', route: '/installation/phase1-redis' },
{ key: 'database', name: '資料庫連接設定', icon: '🗄️', route: '/installation/phase2-database' },
{ key: 'keycloak', name: 'Keycloak SSO 設定', icon: '🔐', route: '/installation/phase3-keycloak' },
].map((step) => {
const isConfigured = status.configured_categories.includes(step.key)
const config = configDetails[step.key as keyof ConfigDetail]
// 格式化配置資訊
let configInfo = ''
if (config) {
if (step.key === 'redis') {
configInfo = `${config.host}:${config.port} (DB ${config.db})`
} else if (step.key === 'database') {
configInfo = `${config.host}:${config.port}/${config.name}`
} else if (step.key === 'keycloak') {
configInfo = `${config.url} (${config.realm})`
}
}
return (
<div
key={step.key}
className={`p-4 rounded-lg border ${
isConfigured
? 'bg-green-900/20 border-green-700'
: 'bg-gray-700/50 border-gray-600'
}`}
>
<div className="flex items-center">
<span className="text-2xl mr-4">{step.icon}</span>
<div className="flex-1">
<div className={`font-medium ${isConfigured ? 'text-green-300' : 'text-gray-200'}`}>
{step.name}
</div>
{isConfigured && configInfo && (
<div className="text-xs text-green-400/70 mt-1 font-mono">
{configInfo}
</div>
)}
</div>
{isConfigured ? (
<span className="text-sm text-green-400 font-medium flex items-center">
<svg className="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</span>
) : (
<button
onClick={() => router.push(step.route)}
className="text-sm text-indigo-400 hover:text-indigo-300 font-medium"
>
</button>
)}
</div>
</div>
)
})}
</div>
{/* Action Buttons */}
<div className="space-y-3">
{status.missing_categories.length > 0 ? (
<button
onClick={startInstallation}
className="w-full bg-gradient-to-r from-indigo-600 to-purple-600 text-white py-4 px-6 rounded-lg font-semibold text-lg hover:from-indigo-700 hover:to-purple-700 transition-all transform hover:scale-105 shadow-lg"
>
</button>
) : (
<button
onClick={() => router.push('/installation/complete')}
className="w-full bg-gradient-to-r from-green-600 to-emerald-600 text-white py-4 px-6 rounded-lg font-semibold text-lg hover:from-green-700 hover:to-emerald-700 transition-all transform hover:scale-105 shadow-lg hover:shadow-green-500/50"
>
</button>
)}
{/* 開發測試:重置按鈕 */}
{status.configured_count > 0 && (
<button
onClick={async () => {
if (!confirm('確定要重置所有環境配置?此操作無法復原。')) return
try {
const response = await fetch('http://10.1.0.245:10181/api/v1/installation/reset-config/all', {
method: 'DELETE'
})
const data = await response.json()
if (data.success) {
alert('重置成功!頁面即將重新載入。')
window.location.reload()
} else {
alert('重置失敗:' + data.message)
}
} catch (error) {
alert('重置失敗:' + (error instanceof Error ? error.message : '未知錯誤'))
}
}}
className="w-full bg-red-600/20 border border-red-700 text-red-300 py-2 px-4 rounded-lg text-sm hover:bg-red-600/30 transition-all"
>
🔄
</button>
)}
</div>
</>
)}
{/* Operational Phase Actions */}
{status.current_phase === 'operational' && (
<div className="space-y-4">
<button
onClick={() => router.push('/installation/health-check')}
className="w-full bg-gradient-to-r from-green-600 to-emerald-600 text-white py-4 px-6 rounded-lg font-semibold text-lg hover:from-green-700 hover:to-emerald-700 transition-all shadow-lg hover:shadow-green-500/50"
>
</button>
<button
onClick={() => router.push('/dashboard')}
className="w-full bg-gray-700 text-gray-100 border border-gray-600 py-4 px-6 rounded-lg font-semibold text-lg hover:bg-gray-600 transition-all shadow-sm"
>
</button>
</div>
)}
{/* Transition Phase Actions */}
{status.current_phase === 'transition' && (
<div className="space-y-4">
<button
onClick={() => router.push('/installation/consistency-check')}
className="w-full bg-gradient-to-r from-orange-600 to-amber-600 text-white py-4 px-6 rounded-lg font-semibold text-lg hover:from-orange-700 hover:to-amber-700 transition-all shadow-lg hover:shadow-orange-500/50"
>
</button>
{status.env_db_consistent && (
<button
onClick={() => {/* TODO: Switch to operational */}}
className="w-full bg-gradient-to-r from-green-600 to-emerald-600 text-white py-4 px-6 rounded-lg font-semibold text-lg hover:from-green-700 hover:to-emerald-700 transition-all shadow-lg hover:shadow-green-500/50"
>
</button>
)}
</div>
)}
</div>
)}
{/* Info Card */}
{status && (
<div className={`${phaseConfig?.bgColor} border ${phaseConfig?.borderColor} rounded-lg p-6`}>
<h3 className={`font-semibold ${phaseConfig?.textColor} mb-3 flex items-center`}>
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
{status.current_phase === 'initialization' && '初始化階段說明'}
{status.current_phase === 'operational' && '營運階段說明'}
{status.current_phase === 'transition' && '移轉階段說明'}
</h3>
<ul className={`space-y-2 text-sm ${phaseConfig?.textColor}`}>
{status.current_phase === 'initialization' && (
<>
<li className="flex items-start">
<span className="mr-2"></span>
<span> .env </span>
</li>
<li className="flex items-start">
<span className="mr-2"></span>
<span></span>
</li>
<li className="flex items-start">
<span className="mr-2"></span>
<span></span>
</li>
</>
)}
{status.current_phase === 'operational' && (
<>
<li className="flex items-start">
<span className="mr-2"></span>
<span></span>
</li>
<li className="flex items-start">
<span className="mr-2"></span>
<span> RedisDatabaseKeycloak </span>
</li>
<li className="flex items-start">
<span className="mr-2"></span>
<span> Transition </span>
</li>
</>
)}
{status.current_phase === 'transition' && (
<>
<li className="flex items-start">
<span className="mr-2"></span>
<span>Transition </span>
</li>
<li className="flex items-start">
<span className="mr-2"></span>
<span> .env </span>
</li>
<li className="flex items-start">
<span className="mr-2"></span>
<span></span>
</li>
</>
)}
</ul>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,295 @@
/**
* Phase 1: Redis 快取設定
*
* 功能:
* 1. 填寫 Redis 連接資訊
* 2. 測試 Redis 連接
* 3. 將設定寫入 .env 檔案
*/
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
interface RedisConfig {
host: string
port: number
password: string
db: number
}
interface TestResult {
success: boolean
message: string
redis_version?: string
memory_usage?: string
}
export default function Phase1Redis() {
const router = useRouter()
const [config, setConfig] = useState<RedisConfig>({
host: '10.1.0.20',
port: 6379,
password: '',
db: 0,
})
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<TestResult | null>(null)
const [setupPending, setSetupPending] = useState(false)
const [loading, setLoading] = useState(true)
const [alreadyConfigured, setAlreadyConfigured] = useState(false)
// 載入已儲存的配置
useEffect(() => {
loadSavedConfig()
}, [])
const loadSavedConfig = async () => {
try {
setLoading(true)
const response = await fetch('http://10.1.0.245:10181/api/v1/installation/get-config/redis')
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
if (data.configured && data.config) {
setConfig({
host: data.config.host || '10.1.0.20',
port: parseInt(data.config.port) || 6379,
password: data.config.password === '****' ? '' : (data.config.password || ''),
db: parseInt(data.config.db) || 0,
})
setAlreadyConfigured(true)
}
} catch (error) {
console.error('[Redis] Failed to load saved config:', error)
} finally {
setLoading(false)
}
}
const handleTest = async () => {
try {
setTesting(true)
setTestResult(null)
const response = await fetch('http://10.1.0.245:10181/api/v1/installation/test-redis', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
const data = await response.json()
if (!response.ok) {
setTestResult({
success: false,
message: data.detail || `HTTP ${response.status}`,
})
return
}
setTestResult(data)
if (data.success) {
setSetupPending(true)
}
} catch (error) {
setTestResult({
success: false,
message: error instanceof Error ? error.message : '連接失敗',
})
} finally {
setTesting(false)
}
}
const handleSetup = async () => {
try {
const response = await fetch('http://10.1.0.245:10181/api/v1/installation/setup-redis', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
const data = await response.json()
if (!response.ok) {
alert(`設定失敗: ${data.detail}`)
return
}
alert('Redis 設定成功!')
router.push('/installation/phase2-database')
} catch (error) {
alert(`設定失敗: ${error instanceof Error ? error.message : '未知錯誤'}`)
}
}
if (loading) {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-red-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-400">...</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-950">
<div className="container mx-auto px-4 py-8">
<div className="max-w-3xl mx-auto">
{/* Progress Bar */}
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-300"></span>
<span className="text-sm font-medium text-gray-300">1 / 7</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div className="bg-red-600 h-2 rounded-full" style={{ width: '14.3%' }}></div>
</div>
</div>
{/* Main Card */}
<div className="bg-gray-800 rounded-xl shadow-xl p-8 border border-gray-700">
<div className="flex items-center mb-6">
<div className="w-12 h-12 bg-red-900/30 rounded-full flex items-center justify-center mr-4 border border-red-800">
<span className="text-2xl">🔴</span>
</div>
<div>
<h1 className="text-2xl font-bold text-gray-100">Phase 1: Redis </h1>
<p className="text-gray-400"> Session </p>
</div>
</div>
{alreadyConfigured && (
<div className="bg-green-900/20 border border-green-700 rounded-lg p-4 mb-6">
<p className="text-sm text-green-300 flex items-center">
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<strong></strong> -
</p>
</div>
)}
<div className="bg-blue-900/20 border border-blue-700 rounded-lg p-4 mb-6">
<p className="text-sm text-blue-300">
<strong></strong>Redis 使 Session NextAuth Redis
</p>
</div>
{/* Form */}
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Redis <span className="text-red-400">*</span>
</label>
<input
type="text"
value={config.host}
onChange={(e) => setConfig({ ...config, host: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-red-500 focus:border-transparent"
placeholder="例如: 10.1.0.20"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<span className="text-red-400">*</span>
</label>
<input
type="number"
value={config.port}
onChange={(e) => setConfig({ ...config, port: parseInt(e.target.value) || 6379 })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
</label>
<input
type="number"
value={config.db}
onChange={(e) => setConfig({ ...config, db: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
()
</label>
<input
type="password"
value={config.password}
onChange={(e) => setConfig({ ...config, password: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-red-500 focus:border-transparent"
placeholder="留空表示無密碼"
/>
</div>
</div>
{/* Test Result */}
{testResult && (
<div className={`mb-6 p-4 rounded-lg ${testResult.success ? 'bg-green-900/20 border border-green-700' : 'bg-red-900/20 border border-red-700'}`}>
<div className="flex items-start">
<span className="text-2xl mr-3">{testResult.success ? '✅' : '❌'}</span>
<div className="flex-1">
<h3 className={`font-semibold mb-1 ${testResult.success ? 'text-green-300' : 'text-red-300'}`}>
{testResult.success ? '連接成功' : '連接失敗'}
</h3>
<p className={`text-sm ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>
{testResult.message}
</p>
{testResult.success && testResult.redis_version && (
<div className="mt-2 text-sm text-green-400">
<p>Redis : {testResult.redis_version}</p>
{testResult.memory_usage && <p>使: {testResult.memory_usage}</p>}
</div>
)}
</div>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-4">
<button
onClick={() => router.back()}
className="px-6 py-2 border border-gray-600 rounded-lg text-gray-300 hover:bg-gray-700 transition-colors"
>
</button>
<button
onClick={handleTest}
disabled={testing || !config.host}
className="flex-1 bg-blue-600 text-white py-2 px-6 rounded-lg hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors"
>
{testing ? '測試中...' : '測試連接'}
</button>
<button
onClick={handleSetup}
disabled={!setupPending}
className="flex-1 bg-red-600 text-white py-2 px-6 rounded-lg hover:bg-red-700 disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors font-semibold"
>
</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,314 @@
/**
* Phase 2: 資料庫連接設定
*
* 功能:
* 1. 填寫資料庫連接資訊
* 2. 測試資料庫連接
* 3. 將設定寫入 .env 檔案
*/
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
interface DatabaseConfig {
host: string
port: number
database: string
user: string
password: string
}
interface TestResult {
success: boolean
message: string
db_version?: string
connection_info?: string
}
export default function Phase2Database() {
const router = useRouter()
const [config, setConfig] = useState<DatabaseConfig>({
host: '10.1.0.20',
port: 5433,
database: 'hr_portal',
user: 'admin',
password: '',
})
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<TestResult | null>(null)
const [setupPending, setSetupPending] = useState(false)
const [loading, setLoading] = useState(true)
const [alreadyConfigured, setAlreadyConfigured] = useState(false)
// 載入已儲存的配置
useEffect(() => {
loadSavedConfig()
}, [])
const loadSavedConfig = async () => {
try {
setLoading(true)
const response = await fetch('http://10.1.0.245:10181/api/v1/installation/get-config/database')
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
if (data.configured && data.config) {
setConfig({
host: data.config.host || '10.1.0.20',
port: parseInt(data.config.port) || 5433,
database: data.config.database || 'hr_portal',
user: data.config.user || 'admin',
password: data.config.password === '****' ? '' : (data.config.password || ''),
})
setAlreadyConfigured(true)
}
} catch (error) {
console.error('[Database] Failed to load saved config:', error)
} finally {
setLoading(false)
}
}
const handleTest = async () => {
try {
setTesting(true)
setTestResult(null)
const response = await fetch('http://10.1.0.245:10181/api/v1/installation/test-database', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
const data = await response.json()
if (!response.ok) {
setTestResult({
success: false,
message: data.detail || `HTTP ${response.status}`,
})
return
}
setTestResult(data)
if (data.success) {
setSetupPending(true)
}
} catch (error) {
setTestResult({
success: false,
message: error instanceof Error ? error.message : '連接失敗',
})
} finally {
setTesting(false)
}
}
const handleSetup = async () => {
try {
const response = await fetch('http://10.1.0.245:10181/api/v1/installation/setup-database', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
const data = await response.json()
if (!response.ok) {
alert(`設定失敗: ${data.detail}`)
return
}
alert('資料庫設定成功!')
router.push('/installation/phase3-keycloak')
} catch (error) {
alert(`設定失敗: ${error instanceof Error ? error.message : '未知錯誤'}`)
}
}
if (loading) {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-400">...</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-950">
<div className="container mx-auto px-4 py-8">
<div className="max-w-3xl mx-auto">
{/* Progress Bar */}
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-300"></span>
<span className="text-sm font-medium text-gray-300">2 / 7</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div className="bg-blue-600 h-2 rounded-full" style={{ width: '28.6%' }}></div>
</div>
</div>
{/* Main Card */}
<div className="bg-gray-800 rounded-xl shadow-xl p-8 border border-gray-700">
<div className="flex items-center mb-6">
<div className="w-12 h-12 bg-blue-900/30 rounded-full flex items-center justify-center mr-4 border border-blue-800">
<span className="text-2xl">🗄</span>
</div>
<div>
<h1 className="text-2xl font-bold text-gray-100">Phase 2: 資料庫連接設定</h1>
<p className="text-gray-400"> PostgreSQL </p>
</div>
</div>
{alreadyConfigured && (
<div className="bg-green-900/20 border border-green-700 rounded-lg p-4 mb-6">
<p className="text-sm text-green-300 flex items-center">
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<strong></strong> -
</p>
</div>
)}
<div className="bg-blue-900/20 border border-blue-700 rounded-lg p-4 mb-6">
<p className="text-sm text-blue-300">
<strong></strong>PostgreSQL HR Portal
</p>
</div>
{/* Form */}
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<span className="text-blue-400">*</span>
</label>
<input
type="text"
value={config.host}
onChange={(e) => setConfig({ ...config, host: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="例如: 10.1.0.20"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<span className="text-blue-400">*</span>
</label>
<input
type="number"
value={config.port}
onChange={(e) => setConfig({ ...config, port: parseInt(e.target.value) || 5432 })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<span className="text-blue-400">*</span>
</label>
<input
type="text"
value={config.database}
onChange={(e) => setConfig({ ...config, database: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="hr_portal"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
使 <span className="text-blue-400">*</span>
</label>
<input
type="text"
value={config.user}
onChange={(e) => setConfig({ ...config, user: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="admin"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<span className="text-blue-400">*</span>
</label>
<input
type="password"
value={config.password}
onChange={(e) => setConfig({ ...config, password: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="資料庫密碼"
/>
</div>
</div>
</div>
{/* Test Result */}
{testResult && (
<div className={`mb-6 p-4 rounded-lg ${testResult.success ? 'bg-green-900/20 border border-green-700' : 'bg-red-900/20 border border-red-700'}`}>
<div className="flex items-start">
<span className="text-2xl mr-3">{testResult.success ? '✅' : '❌'}</span>
<div className="flex-1">
<h3 className={`font-semibold mb-1 ${testResult.success ? 'text-green-300' : 'text-red-300'}`}>
{testResult.success ? '連接成功' : '連接失敗'}
</h3>
<p className={`text-sm ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>
{testResult.message}
</p>
{testResult.success && testResult.db_version && (
<div className="mt-2 text-sm text-green-400">
<p>: {testResult.db_version}</p>
{testResult.connection_info && <p>: {testResult.connection_info}</p>}
</div>
)}
</div>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-4">
<button
onClick={() => router.back()}
className="px-6 py-2 border border-gray-600 rounded-lg text-gray-300 hover:bg-gray-700 transition-colors"
>
</button>
<button
onClick={handleTest}
disabled={testing || !config.host || !config.database || !config.user || !config.password}
className="flex-1 bg-blue-600 text-white py-2 px-6 rounded-lg hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors"
>
{testing ? '測試中...' : '測試連接'}
</button>
<button
onClick={handleSetup}
disabled={!setupPending}
className="flex-1 bg-blue-600 text-white py-2 px-6 rounded-lg hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors font-semibold"
>
</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,297 @@
/**
* Phase 3: Keycloak SSO 設定
*
* 功能:
* 1. 填寫 Keycloak 連接資訊
* 2. 測試 Keycloak 連接
* 3. 將設定寫入 .env 檔案
*/
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
interface KeycloakConfig {
url: string
realm: string
admin_username: string
admin_password: string
}
interface TestResult {
success: boolean
message: string
keycloak_version?: string
realm_info?: string
}
export default function Phase3Keycloak() {
const router = useRouter()
const [config, setConfig] = useState<KeycloakConfig>({
url: 'https://auth.lab.taipei',
realm: 'porscheworld',
admin_username: 'admin',
admin_password: '',
})
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<TestResult | null>(null)
const [setupPending, setSetupPending] = useState(false)
const [loading, setLoading] = useState(true)
const [alreadyConfigured, setAlreadyConfigured] = useState(false)
// 載入已儲存的配置
useEffect(() => {
loadSavedConfig()
}, [])
const loadSavedConfig = async () => {
try {
setLoading(true)
const response = await fetch('http://10.1.0.245:10181/api/v1/installation/get-config/keycloak')
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
if (data.configured && data.config) {
setConfig({
url: data.config.url || 'https://auth.lab.taipei',
realm: data.config.realm || 'porscheworld',
admin_username: data.config.admin_username || 'admin',
admin_password: data.config.admin_password === '****' ? '' : (data.config.admin_password || ''),
})
setAlreadyConfigured(true)
}
} catch (error) {
console.error('[Keycloak] Failed to load saved config:', error)
} finally {
setLoading(false)
}
}
const handleTest = async () => {
try {
setTesting(true)
setTestResult(null)
const response = await fetch('http://10.1.0.245:10181/api/v1/installation/test-keycloak', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
const data = await response.json()
if (!response.ok) {
setTestResult({
success: false,
message: data.detail || `HTTP ${response.status}`,
})
return
}
setTestResult(data)
if (data.success) {
setSetupPending(true)
}
} catch (error) {
setTestResult({
success: false,
message: error instanceof Error ? error.message : '連接失敗',
})
} finally {
setTesting(false)
}
}
const handleSetup = async () => {
try {
const response = await fetch('http://10.1.0.245:10181/api/v1/installation/setup-keycloak', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
const data = await response.json()
if (!response.ok) {
alert(`設定失敗: ${data.detail}`)
return
}
alert('Keycloak 設定成功!')
router.push('/installation')
} catch (error) {
alert(`設定失敗: ${error instanceof Error ? error.message : '未知錯誤'}`)
}
}
if (loading) {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-green-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-400">...</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-950">
<div className="container mx-auto px-4 py-8">
<div className="max-w-3xl mx-auto">
{/* Progress Bar */}
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-300"></span>
<span className="text-sm font-medium text-gray-300">3 / 7</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div className="bg-green-600 h-2 rounded-full" style={{ width: '42.9%' }}></div>
</div>
</div>
{/* Main Card */}
<div className="bg-gray-800 rounded-xl shadow-xl p-8 border border-gray-700">
<div className="flex items-center mb-6">
<div className="w-12 h-12 bg-green-900/30 rounded-full flex items-center justify-center mr-4 border border-green-800">
<span className="text-2xl">🔐</span>
</div>
<div>
<h1 className="text-2xl font-bold text-gray-100">Phase 3: Keycloak SSO </h1>
<p className="text-gray-400"></p>
</div>
</div>
{alreadyConfigured && (
<div className="bg-green-900/20 border border-green-700 rounded-lg p-4 mb-6">
<p className="text-sm text-green-300 flex items-center">
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<strong></strong> -
</p>
</div>
)}
<div className="bg-blue-900/20 border border-blue-700 rounded-lg p-4 mb-6">
<p className="text-sm text-blue-300">
<strong></strong>Keycloak SSO 使HR Portal Keycloak
</p>
</div>
{/* Form */}
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Keycloak <span className="text-green-400">*</span>
</label>
<input
type="text"
value={config.url}
onChange={(e) => setConfig({ ...config, url: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="例如: https://auth.lab.taipei"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Realm <span className="text-green-400">*</span>
</label>
<input
type="text"
value={config.realm}
onChange={(e) => setConfig({ ...config, realm: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="porscheworld"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<span className="text-green-400">*</span>
</label>
<input
type="text"
value={config.admin_username}
onChange={(e) => setConfig({ ...config, admin_username: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="admin"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<span className="text-green-400">*</span>
</label>
<input
type="password"
value={config.admin_password}
onChange={(e) => setConfig({ ...config, admin_password: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="管理員密碼"
/>
</div>
</div>
{/* Test Result */}
{testResult && (
<div className={`mb-6 p-4 rounded-lg ${testResult.success ? 'bg-green-900/20 border border-green-700' : 'bg-red-900/20 border border-red-700'}`}>
<div className="flex items-start">
<span className="text-2xl mr-3">{testResult.success ? '✅' : '❌'}</span>
<div className="flex-1">
<h3 className={`font-semibold mb-1 ${testResult.success ? 'text-green-300' : 'text-red-300'}`}>
{testResult.success ? '連接成功' : '連接失敗'}
</h3>
<p className={`text-sm ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>
{testResult.message}
</p>
{testResult.success && testResult.keycloak_version && (
<div className="mt-2 text-sm text-green-400">
<p>Keycloak : {testResult.keycloak_version}</p>
{testResult.realm_info && <p>Realm : {testResult.realm_info}</p>}
</div>
)}
</div>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-4">
<button
onClick={() => router.back()}
className="px-6 py-2 border border-gray-600 rounded-lg text-gray-300 hover:bg-gray-700 transition-colors"
>
</button>
<button
onClick={handleTest}
disabled={testing || !config.url || !config.realm || !config.admin_username || !config.admin_password}
className="flex-1 bg-blue-600 text-white py-2 px-6 rounded-lg hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors"
>
{testing ? '測試中...' : '測試連接'}
</button>
<button
onClick={handleSetup}
disabled={!setupPending}
className="flex-1 bg-green-600 text-white py-2 px-6 rounded-lg hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors font-semibold"
>
</button>
</div>
</div>
</div>
</div>
</div>
)
}

35
frontend/app/layout.tsx Normal file
View File

@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { SessionProvider } from "@/components/auth/session-provider";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "HR Portal - 人力資源管理系統",
description: "Porsche World 人力資源管理系統",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-TW">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<SessionProvider>{children}</SessionProvider>
</body>
</html>
);
}

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

102
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,102 @@
/**
* 首頁 - 檢查系統初始化狀態後導向
*
* 流程:
* 1. 檢查系統是否已初始化
* 2. 如果未初始化 → 導向 /installation
* 3. 如果已初始化 → 檢查登入狀態
* - 已登入 → /dashboard
* - 未登入 → /auth/signin
*/
'use client'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
export default function Home() {
const { data: session, status } = useSession()
const router = useRouter()
const [systemChecked, setSystemChecked] = useState(false)
const [systemInitialized, setSystemInitialized] = useState(false)
// 第一步:檢查系統初始化狀態
useEffect(() => {
checkSystemStatus()
}, [])
// 第二步:根據初始化狀態和登入狀態決定導向
useEffect(() => {
if (!systemChecked) {
return // 尚未檢查完成,等待
}
// 如果系統未初始化,直接導向初始化頁面
if (!systemInitialized) {
console.log('[Home] System not initialized, redirecting to /installation')
router.push('/installation')
return
}
// 系統已初始化,檢查登入狀態
const logMsg = `[Home] Status: ${status}, Has session: ${!!session}, Has user: ${!!session?.user}, Email: ${session?.user?.email || 'N/A'}`
console.log(logMsg)
// 存到 sessionStorage 以便 debug
const logs = sessionStorage.getItem('auth_logs') || ''
sessionStorage.setItem('auth_logs', logs + '\n' + new Date().toISOString() + ' - ' + logMsg)
// 等待 session 載入完成後再導向
if (status === 'loading') {
console.log('[Home] Still loading, waiting...')
return // 仍在載入中,不做任何動作
}
if (status === 'authenticated' && session?.user) {
console.log('[Home] ✅ Authenticated, redirecting to dashboard')
sessionStorage.setItem('auth_logs', sessionStorage.getItem('auth_logs') + '\n✅ REDIRECTING TO DASHBOARD')
router.push('/dashboard')
} else if (status === 'unauthenticated') {
console.log('[Home] ❌ Unauthenticated, redirecting to signin')
sessionStorage.setItem('auth_logs', sessionStorage.getItem('auth_logs') + '\n❌ REDIRECTING TO SIGNIN')
router.push('/auth/signin')
} else {
console.log('[Home] ⚠️ Unexpected state - status:', status, 'has session:', !!session)
}
}, [systemChecked, systemInitialized, status, session, router])
const checkSystemStatus = async () => {
try {
const response = await fetch('http://10.1.0.245:10181/api/v1/installation/check-status')
if (!response.ok) {
console.error('[Home] Failed to check system status:', response.statusText)
// 如果無法連接後端,假設未初始化
setSystemInitialized(false)
setSystemChecked(true)
return
}
const data = await response.json()
console.log('[Home] System status:', data)
setSystemInitialized(data.is_initialized || false)
} catch (error) {
console.error('[Home] Error checking system status:', error)
// 連接錯誤,假設未初始化
setSystemInitialized(false)
} finally {
setSystemChecked(true)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
<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>
<h1 className="text-2xl font-bold text-gray-900 mb-2">HR Portal</h1>
<p className="text-gray-600">...</p>
{status && <p className="text-xs text-gray-400 mt-2">: {status}</p>}
</div>
</div>
)
}

View File

@@ -0,0 +1,428 @@
'use client'
import { useState, useEffect } from 'react'
import apiClient from '@/lib/api-client'
import AlertDialog from '@/components/ui/AlertDialog'
interface SystemFunction {
id?: number
code: string
name: string
function_type: number
upper_function_id: number
order: number
function_icon: string
module_code: string | null
module_functions: string[]
description: string
is_mana: boolean
is_active: boolean
edit_by: number
}
interface Props {
function: SystemFunction | null
onClose: () => void
onSave: (isEdit: boolean) => void
}
const AVAILABLE_OPERATIONS = [
{ value: 'View', label: 'View' },
{ value: 'Create', label: 'Create' },
{ value: 'Read', label: 'Read' },
{ value: 'Update', label: 'Update' },
{ value: 'Delete', label: 'Delete' },
{ value: 'Print', label: 'Print' },
{ value: 'File', label: 'File' },
]
export default function FunctionFormModal({ function: editingFunction, onClose, onSave }: Props) {
const [formData, setFormData] = useState<SystemFunction>({
code: '',
name: '',
function_type: 2,
upper_function_id: 0,
order: 10,
function_icon: '',
module_code: null,
module_functions: [],
description: '',
is_mana: false,
is_active: true,
edit_by: 1,
})
const [parentFunctions, setParentFunctions] = useState<any[]>([])
const [loading, setLoading] = useState(false)
// 對話框狀態
const [alertDialog, setAlertDialog] = useState<{
isOpen: boolean
title: string
message: string
type: 'info' | 'warning' | 'error' | 'success'
}>({
isOpen: false,
title: '',
message: '',
type: 'info',
})
// 載入上層功能選項 (只顯示 function_type=1 的 NODE)
useEffect(() => {
const loadParentFunctions = async () => {
try {
const response: any = await apiClient.get('/system-functions?function_type=1&page_size=100')
setParentFunctions(response.items || [])
} catch (error) {
console.error('Failed to load parent functions:', error)
}
}
loadParentFunctions()
}, [])
// 如果是編輯模式,填入現有資料
useEffect(() => {
if (editingFunction) {
setFormData(editingFunction)
}
}, [editingFunction])
const handleChange = (field: keyof SystemFunction, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }))
}
const handleOperationToggle = (operation: string) => {
const currentOperations = formData.module_functions || []
const newOperations = currentOperations.includes(operation)
? currentOperations.filter(op => op !== operation)
: [...currentOperations, operation]
setFormData(prev => ({ ...prev, module_functions: newOperations }))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
// 驗證
if (!formData.code || !formData.name) {
setAlertDialog({
isOpen: true,
title: '欄位驗證',
message: '請填寫功能代碼和名稱',
type: 'warning',
})
setLoading(false)
return
}
if (formData.function_type === 2 && !formData.module_code) {
setAlertDialog({
isOpen: true,
title: '欄位驗證',
message: 'FUNCTION 類型需要填寫模組代碼',
type: 'warning',
})
setLoading(false)
return
}
// 準備資料
const submitData = {
...formData,
module_code: formData.function_type === 1 ? null : formData.module_code,
module_functions: formData.function_type === 1 ? [] : formData.module_functions,
}
const isEdit = !!editingFunction
if (isEdit) {
// 更新
await apiClient.put(`/system-functions/${editingFunction.id}`, submitData)
} else {
// 新增
await apiClient.post('/system-functions', submitData)
}
onSave(isEdit)
onClose()
} catch (error: any) {
console.error('Failed to save function:', error)
setAlertDialog({
isOpen: true,
title: '儲存失敗',
message: error.response?.data?.detail || '儲存失敗,請稍後再試',
type: 'error',
})
} finally {
setLoading(false)
}
}
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 animate-fadeIn"
onClick={onClose}
>
<div
className="bg-white rounded-xl shadow-2xl w-full max-w-5xl max-h-[90vh] overflow-hidden animate-slideIn"
onClick={(e) => e.stopPropagation()}
>
{/* Card Header */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 px-5 py-3.5 flex justify-between items-center">
<h2 className="text-lg font-semibold text-white">
{formData.name || '系統功能'} - {editingFunction ? '編輯作業' : '新增作業'}
</h2>
<button
type="button"
onClick={onClose}
className="text-white hover:text-blue-100 transition-colors"
aria-label="關閉"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Card Body - Scrollable */}
<div className="overflow-y-auto max-h-[calc(90vh-180px)]">
<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">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.code}
onChange={(e) => handleChange('code', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
placeholder="例如: dashboard"
required
/>
</div>
<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)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
placeholder="例如: 系統首頁"
required
/>
</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>
<div className="flex gap-3">
<label className="flex items-center cursor-pointer">
<input
type="radio"
value={1}
checked={formData.function_type === 1}
onChange={(e) => handleChange('function_type', parseInt(e.target.value))}
className="mr-2"
/>
<span className="px-2 py-1 bg-purple-100 text-purple-800 rounded text-xs font-medium">NODE</span>
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
value={2}
checked={formData.function_type === 2}
onChange={(e) => handleChange('function_type', parseInt(e.target.value))}
className="mr-2"
/>
<span className="px-2 py-1 bg-green-100 text-green-800 rounded text-xs font-medium">FUNCTION</span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
value={formData.upper_function_id}
onChange={(e) => handleChange('upper_function_id', parseInt(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
>
<option value={0}> ()</option>
{parentFunctions.map(func => (
<option key={func.id} value={func.id}>
{func.name}
</option>
))}
</select>
</div>
</div>
{/* Row 3: 順序 + Emoji 圖示 */}
<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="number"
value={formData.order}
onChange={(e) => handleChange('order', parseInt(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
min={1}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Emoji
</label>
<div className="flex items-center gap-2">
<input
type="text"
value={formData.function_icon}
onChange={(e) => handleChange('function_icon', e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
placeholder="📊"
/>
{formData.function_icon && (
<span className="text-2xl">{formData.function_icon}</span>
)}
</div>
</div>
</div>
{/* Row 4: 模組代碼 (只在 FUNCTION 時顯示) */}
{formData.function_type === 2 && (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.module_code || ''}
onChange={(e) => handleChange('module_code', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
placeholder="例如: dashboard"
required
/>
</div>
)}
{/* 模組操作權限 (橫置) */}
{formData.function_type === 2 && (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="flex flex-wrap gap-3">
{AVAILABLE_OPERATIONS.map(op => (
<label
key={op.value}
className={`px-3 py-2 rounded-lg border-2 cursor-pointer transition-all text-sm ${
formData.module_functions.includes(op.value)
? 'bg-blue-50 border-blue-500 text-blue-700'
: 'bg-white border-gray-300 text-gray-700 hover:border-blue-300'
}`}
>
<input
type="checkbox"
checked={formData.module_functions.includes(op.value)}
onChange={() => handleOperationToggle(op.value)}
className="mr-2"
/>
{op.label}
</label>
))}
</div>
</div>
)}
{/* 說明 */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<textarea
value={formData.description}
onChange={(e) => handleChange('description', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
rows={2}
placeholder="功能說明..."
/>
</div>
{/* Row 5: 系統管理 + 啟用 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
checked={formData.is_mana}
onChange={(e) => handleChange('is_mana', e.target.checked)}
className="mr-2 w-4 h-4"
/>
<span className="text-sm font-medium text-gray-700"></span>
</label>
</div>
<div>
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => handleChange('is_active', e.target.checked)}
className="mr-2 w-4 h-4"
/>
<span className="text-sm font-medium text-gray-700"></span>
</label>
</div>
</div>
</form>
</div>
{/* Card Footer */}
<div className="border-t bg-gray-50 px-5 py-2.5 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-1.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors text-sm font-medium"
disabled={loading}
>
</button>
<button
type="submit"
onClick={handleSubmit}
className="px-4 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 text-sm font-medium"
disabled={loading}
>
{loading ? '儲存中...' : '儲存'}
</button>
</div>
{/* Alert Dialog */}
<AlertDialog
isOpen={alertDialog.isOpen}
title={alertDialog.title}
message={alertDialog.message}
type={alertDialog.type}
onConfirm={() => setAlertDialog({ ...alertDialog, isOpen: false })}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,53 @@
/**
* System Functions 佈局
*/
'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 SystemFunctionsLayout({ 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>
)
}

View File

@@ -0,0 +1,600 @@
'use client'
import { useState, useEffect } from 'react'
import { useSession } from 'next-auth/react'
import apiClient from '@/lib/api-client'
import FunctionFormModal from './FunctionFormModal'
import AlertDialog from '@/components/ui/AlertDialog'
import ConfirmDialog from '@/components/ui/ConfirmDialog'
interface SystemFunction {
id: number
code: string
name: string
function_type: number // 1=NODE, 2=FUNCTION
upper_function_id: number
order: number
function_icon: string
module_code: string | null
module_functions: string[]
description: string
is_mana: boolean
is_active: boolean
}
interface ParentFunction {
id: number
code: string
name: string
}
export default function SystemFunctionsPage() {
const { data: session } = useSession()
const [allFunctions, setAllFunctions] = useState<SystemFunction[]>([]) // 所有資料
const [functions, setFunctions] = useState<SystemFunction[]>([]) // 當前頁面顯示的資料
const [parentFunctions, setParentFunctions] = useState<ParentFunction[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingFunction, setEditingFunction] = useState<SystemFunction | null>(null)
// 對話框狀態
const [alertDialog, setAlertDialog] = useState<{
isOpen: boolean
title: string
message: string
type: 'info' | 'warning' | 'error' | 'success'
}>({
isOpen: false,
title: '',
message: '',
type: 'info',
})
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean
title: string
message: string
type: 'info' | 'warning' | 'error' | 'success'
onConfirm: () => void
}>({
isOpen: false,
title: '',
message: '',
type: 'warning',
onConfirm: () => {},
})
// 篩選條件
const [filterType, setFilterType] = useState<string>('')
const [filterActive, setFilterActive] = useState<string>('')
const [filterParent, setFilterParent] = useState<string>('')
// 分頁與排序
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(5)
const [sortField, setSortField] = useState<'order' | 'id'>('order')
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
// 載入功能列表
const loadFunctions = async () => {
try {
setLoading(true)
console.log('[SystemFunctions] Loading functions...')
// 建立查詢參數 - 不使用 URLSearchParams直接建立 query string
let queryParts = ['page_size=100'] // 後端限制最大 100
if (filterType) {
queryParts.push(`function_type=${filterType}`)
}
// is_active 特殊處理:空字串表示不篩選,所以不加這個參數
if (filterActive) {
queryParts.push(`is_active=${filterActive}`)
} else {
// 不傳 is_active 參數,讓後端使用預設值或不篩選
// 為了顯示所有資料(包含停用),我們明確傳 is_active=None 或不傳
}
if (filterParent) {
queryParts.push(`upper_function_id=${filterParent}`)
}
const queryString = queryParts.join('&')
console.log('[SystemFunctions] Query:', queryString)
const response: any = await apiClient.get(`/system-functions?${queryString}`)
console.log('[SystemFunctions] Response:', response)
console.log('[SystemFunctions] Items count:', response.items?.length || 0)
setAllFunctions(response.items || [])
setCurrentPage(1) // 重置到第一頁
} catch (error) {
console.error('[SystemFunctions] Failed to load functions:', error)
setAlertDialog({
isOpen: true,
title: '載入失敗',
message: `載入系統功能失敗: ${(error as any)?.message || '未知錯誤'}`,
type: 'error',
})
} finally {
setLoading(false)
}
}
// 載入上層功能選項 (用於篩選)
const loadParentFunctions = async () => {
try {
const response: any = await apiClient.get('/system-functions?function_type=1&page_size=100')
const parents = response.items || []
setParentFunctions([{ id: 0, code: 'root', name: '根層 (無上層)' }, ...parents])
} catch (error) {
console.error('Failed to load parent functions:', error)
}
}
useEffect(() => {
if (session) {
loadParentFunctions()
loadFunctions()
}
}, [session])
// 當篩選條件改變時重新載入
useEffect(() => {
if (session) {
loadFunctions()
}
}, [filterType, filterActive, filterParent])
// 排序和分頁處理
useEffect(() => {
// 排序
const sorted = [...allFunctions].sort((a, b) => {
const aValue = a[sortField]
const bValue = b[sortField]
if (sortDirection === 'asc') {
return aValue > bValue ? 1 : -1
} else {
return aValue < bValue ? 1 : -1
}
})
// 分頁
const startIndex = (currentPage - 1) * pageSize
const endIndex = startIndex + pageSize
const paginated = sorted.slice(startIndex, endIndex)
setFunctions(paginated)
}, [allFunctions, currentPage, pageSize, sortField, sortDirection])
// 排序處理
const handleSort = (field: 'order' | 'id') => {
if (sortField === field) {
// 切換排序方向
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
} else {
// 新欄位,預設升序
setSortField(field)
setSortDirection('asc')
}
}
// 計算總頁數
const totalPages = Math.ceil(allFunctions.length / pageSize)
// 切換頁碼
const handlePageChange = (page: number) => {
if (page >= 1 && page <= totalPages) {
setCurrentPage(page)
}
}
// 產生頁碼按鈕
const renderPageNumbers = () => {
const pages = []
const maxVisiblePages = 5
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2))
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1)
if (endPage - startPage < maxVisiblePages - 1) {
startPage = Math.max(1, endPage - maxVisiblePages + 1)
}
for (let i = startPage; i <= endPage; i++) {
pages.push(
<button
key={i}
onClick={() => handlePageChange(i)}
className={`px-3 py-1 rounded ${
i === currentPage
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-100'
} border border-gray-300`}
>
{i}
</button>
)
}
return pages
}
// 新增功能
const handleCreate = () => {
setEditingFunction(null)
setShowModal(true)
}
// 編輯功能
const handleEdit = (func: SystemFunction) => {
setEditingFunction(func)
setShowModal(true)
}
// 刪除功能 (軟刪除)
const handleDelete = (id: number) => {
setConfirmDialog({
isOpen: true,
title: '確認刪除',
message: '確定要刪除此功能嗎?刪除後將無法復原。',
type: 'warning',
onConfirm: async () => {
try {
await apiClient.delete(`/system-functions/${id}`)
loadFunctions()
setAlertDialog({
isOpen: true,
title: '刪除成功',
message: '功能已成功刪除',
type: 'success',
})
} catch (error) {
console.error('Failed to delete function:', error)
setAlertDialog({
isOpen: true,
title: '刪除失敗',
message: '刪除功能時發生錯誤,請稍後再試',
type: 'error',
})
} finally {
setConfirmDialog({ ...confirmDialog, isOpen: false })
}
},
})
}
// 切換啟用狀態
const handleToggleActive = async (func: SystemFunction) => {
try {
await apiClient.patch(`/system-functions/${func.id}`, {
is_active: !func.is_active,
edit_by: 1 // TODO: 從 session 取得當前用戶 ID
})
loadFunctions()
} catch (error) {
console.error('Failed to toggle active status:', error)
setAlertDialog({
isOpen: true,
title: '操作失敗',
message: '切換狀態失敗,請稍後再試',
type: 'error',
})
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-gray-500">...</div>
</div>
)
}
return (
<div>
{/* Header */}
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">🎯 </h1>
<p className="mt-1 text-sm text-gray-500">
</p>
</div>
<button
onClick={handleCreate}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
+
</button>
</div>
{/* 篩選條件 */}
<div className="mb-4 flex gap-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1"></label>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 bg-white"
>
<option value="" className="text-gray-900"></option>
<option value="1" className="text-gray-900">NODE ()</option>
<option value="2" className="text-gray-900">FUNCTION ()</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1"></label>
<select
value={filterActive}
onChange={(e) => setFilterActive(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 bg-white"
>
<option value="" className="text-gray-900"></option>
<option value="true" className="text-gray-900"></option>
<option value="false" className="text-gray-900"></option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1"></label>
<select
value={filterParent}
onChange={(e) => setFilterParent(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 bg-white"
>
<option value="" className="text-gray-900"></option>
{parentFunctions.map(parent => (
<option key={parent.id} value={parent.id} className="text-gray-900">
{parent.name}
</option>
))}
</select>
</div>
{(filterType || filterActive || filterParent) && (
<div className="flex items-end">
<button
onClick={() => {
setFilterType('')
setFilterActive('')
setFilterParent('')
}}
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-900"
>
</button>
</div>
)}
</div>
{/* 每頁筆數選擇 */}
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<label className="text-sm text-gray-700"></label>
<select
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value))
setCurrentPage(1)
}}
className="px-3 py-1 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 bg-white"
>
<option value={5} className="text-gray-900">5</option>
<option value={10} className="text-gray-900">10</option>
<option value={15} className="text-gray-900">15</option>
<option value={20} className="text-gray-900">20</option>
<option value={25} className="text-gray-900">25</option>
<option value={50} className="text-gray-900">50</option>
</select>
<span className="text-sm text-gray-700"></span>
</div>
<div className="text-sm text-gray-700">
{allFunctions.length}
</div>
</div>
{/* Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-blue-900">
<tr>
<th className="px-6 py-3 text-left text-sm font-medium text-white uppercase tracking-wider">
ID
</th>
<th className="px-6 py-3 text-left text-sm font-medium text-white uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-sm font-medium text-white uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-sm font-medium text-white uppercase tracking-wider">
</th>
<th
className="px-6 py-3 text-left text-sm font-medium text-white uppercase tracking-wider cursor-pointer hover:bg-blue-800"
onClick={() => handleSort('order')}
>
<div className="flex items-center gap-1">
{sortField === 'order' && (
<span className="text-yellow-300">
{sortDirection === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
</th>
<th className="px-6 py-3 text-center text-sm font-medium text-white uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-center text-sm font-medium text-white uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{functions.map((func) => (
<tr key={func.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{func.id}
</td>
<td className="px-6 py-4 whitespace-nowrap text-xl">
{func.function_icon}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
{func.code}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{func.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{func.order}
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<button
onClick={() => handleToggleActive(func)}
className={`px-3 py-1 rounded text-xs font-medium transition-colors cursor-pointer ${
func.is_active
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
}`}
>
{func.is_active ? '是' : '否'}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center text-sm font-medium space-x-3">
<button
onClick={() => handleEdit(func)}
className="px-3 py-1 border border-blue-600 text-blue-600 rounded hover:bg-blue-50 transition-colors"
>
</button>
<button
onClick={() => handleDelete(func.id)}
className="px-3 py-1 border border-red-600 text-red-600 rounded hover:bg-red-50 transition-colors"
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{allFunctions.length === 0 && !loading && (
<div className="text-center py-12 text-gray-500">
</div>
)}
{/* 分頁控制 */}
{allFunctions.length > 0 && (
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-gray-700">
{(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, allFunctions.length)}
{allFunctions.length}
</div>
<div className="flex items-center gap-2">
{/* 第一頁 */}
<button
onClick={() => handlePageChange(1)}
disabled={currentPage === 1}
className="px-3 py-1 rounded bg-white text-gray-700 border border-gray-300 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
««
</button>
{/* 上一頁 */}
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="px-3 py-1 rounded bg-white text-gray-700 border border-gray-300 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
«
</button>
{/* 頁碼 */}
{renderPageNumbers()}
{/* 下一頁 */}
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-3 py-1 rounded bg-white text-gray-700 border border-gray-300 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
»
</button>
{/* 最後一頁 */}
<button
onClick={() => handlePageChange(totalPages)}
disabled={currentPage === totalPages}
className="px-3 py-1 rounded bg-white text-gray-700 border border-gray-300 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
»»
</button>
{/* 跳頁 */}
<div className="flex items-center gap-2 ml-4">
<span className="text-sm text-gray-700"></span>
<input
type="number"
min={1}
max={totalPages}
value={currentPage}
onChange={(e) => {
const page = Number(e.target.value)
if (page >= 1 && page <= totalPages) {
handlePageChange(page)
}
}}
className="w-16 px-2 py-1 border border-gray-300 rounded text-center text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700"></span>
</div>
</div>
</div>
)}
{/* Modal */}
{showModal && (
<FunctionFormModal
function={editingFunction}
onClose={() => setShowModal(false)}
onSave={(isEdit: boolean) => {
setShowModal(false)
loadFunctions()
setAlertDialog({
isOpen: true,
title: '儲存成功',
message: `功能已成功${isEdit ? '更新' : '新增'}`,
type: 'success',
})
}}
/>
)}
{/* Alert Dialog */}
<AlertDialog
isOpen={alertDialog.isOpen}
title={alertDialog.title}
message={alertDialog.message}
type={alertDialog.type}
onConfirm={() => setAlertDialog({ ...alertDialog, isOpen: false })}
/>
{/* Confirm Dialog */}
<ConfirmDialog
isOpen={confirmDialog.isOpen}
title={confirmDialog.title}
message={confirmDialog.message}
type={confirmDialog.type}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog({ ...confirmDialog, isOpen: false })}
/>
</div>
)
}

View File

@@ -0,0 +1,45 @@
'use client'
import { useSession } from 'next-auth/react'
export default function TestSessionPage() {
const { data: session, status } = useSession()
return (
<div style={{ padding: '20px', fontFamily: 'monospace' }}>
<h1>Session Debug Page</h1>
<div style={{ marginTop: '20px' }}>
<h2>Status: {status}</h2>
</div>
<div style={{ marginTop: '20px' }}>
<h2>Session Data:</h2>
<pre style={{ background: '#f5f5f5', padding: '10px', borderRadius: '5px' }}>
{JSON.stringify(session, null, 2)}
</pre>
</div>
<div style={{ marginTop: '20px' }}>
<h2>Session User:</h2>
<pre style={{ background: '#f5f5f5', padding: '10px', borderRadius: '5px' }}>
{JSON.stringify(session?.user, null, 2)}
</pre>
</div>
<div style={{ marginTop: '20px' }}>
<h2>Tenant Info:</h2>
<pre style={{ background: '#f5f5f5', padding: '10px', borderRadius: '5px' }}>
{JSON.stringify((session?.user as any)?.tenant, null, 2)}
</pre>
</div>
<div style={{ marginTop: '20px' }}>
<h2>is_sysmana:</h2>
<pre style={{ background: '#f5f5f5', padding: '10px', borderRadius: '5px', fontSize: '24px', fontWeight: 'bold' }}>
{(session?.user as any)?.tenant?.is_sysmana?.toString() || 'undefined'}
</pre>
</div>
</div>
)
}