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