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:
282
frontend/components/EmployeeStatusCard.tsx
Normal file
282
frontend/components/EmployeeStatusCard.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { onboardingService } from '../services/onboarding.service'
|
||||
import type { EmployeeStatusResponse } from '../types/onboarding'
|
||||
|
||||
interface EmployeeStatusCardProps {
|
||||
tenantId: number
|
||||
seqNo: number
|
||||
showActions?: boolean
|
||||
}
|
||||
|
||||
export default function EmployeeStatusCard({
|
||||
tenantId,
|
||||
seqNo,
|
||||
showActions = false,
|
||||
}: EmployeeStatusCardProps) {
|
||||
const [status, setStatus] = useState<EmployeeStatusResponse | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [successMessage, setSuccessMessage] = useState('')
|
||||
const [isOffboarding, setIsOffboarding] = useState(false)
|
||||
|
||||
const fetchEmployeeStatus = async () => {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const data = await onboardingService.getEmployeeStatus(tenantId, seqNo)
|
||||
setStatus(data)
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.detail || 'Failed to load employee status'
|
||||
setError(message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchEmployeeStatus()
|
||||
}, [tenantId, seqNo])
|
||||
|
||||
const handleOffboard = async () => {
|
||||
if (!confirm('Are you sure you want to offboard this employee?')) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsOffboarding(true)
|
||||
setError('')
|
||||
setSuccessMessage('')
|
||||
|
||||
try {
|
||||
const response = await onboardingService.offboardEmployee(tenantId, seqNo)
|
||||
setSuccessMessage(response.message)
|
||||
// Refresh status after offboarding
|
||||
await fetchEmployeeStatus()
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.detail || 'Failed to offboard employee'
|
||||
setError(message)
|
||||
} finally {
|
||||
setIsOffboarding(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getEmploymentStatusBadge = (status: string) => {
|
||||
const colors: { [key: string]: string } = {
|
||||
active: 'bg-green-100 text-green-800',
|
||||
inactive: 'bg-gray-100 text-gray-800',
|
||||
resigned: 'bg-red-100 text-red-800',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${colors[status] || 'bg-gray-100 text-gray-800'}`}>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const getMembershipTypeBadge = (type: string) => {
|
||||
const colors: { [key: string]: string } = {
|
||||
permanent: 'bg-blue-100 text-blue-800',
|
||||
concurrent: 'bg-purple-100 text-purple-800',
|
||||
temporary: 'bg-yellow-100 text-yellow-800',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${colors[type] || 'bg-gray-100 text-gray-800'}`}>
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-gray-500">Loading employee status...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error && !status) {
|
||||
return (
|
||||
<div className="p-6 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-700">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
{/* Success/Error Messages */}
|
||||
{successMessage && (
|
||||
<div className="p-4 bg-green-100 text-green-700 rounded-lg">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-100 text-red-700 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Employee Basic Information */}
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">{status.employee.name}</h2>
|
||||
<p className="text-gray-600">{status.employee.tenant_emp_code}</p>
|
||||
</div>
|
||||
{getEmploymentStatusBadge(status.employee.employment_status)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Keycloak Username</p>
|
||||
<p className="font-medium">{status.employee.keycloak_username}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Keycloak User ID</p>
|
||||
<p className="font-mono text-sm">{status.employee.keycloak_user_id}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Hire Date</p>
|
||||
<p className="font-medium">{status.employee.hire_date}</p>
|
||||
</div>
|
||||
|
||||
{status.employee.resign_date && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Resign Date</p>
|
||||
<p className="font-medium">{status.employee.resign_date}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Storage Quota</p>
|
||||
<p className="font-medium">{status.employee.storage_quota_gb} GB</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Email Quota</p>
|
||||
<p className="font-medium">{status.employee.email_quota_mb} MB</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Offboard Button */}
|
||||
{showActions && status.employee.employment_status === 'active' && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<button
|
||||
onClick={handleOffboard}
|
||||
disabled={isOffboarding}
|
||||
className={`px-4 py-2 rounded font-medium ${
|
||||
isOffboarding
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-red-500 hover:bg-red-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{isOffboarding ? 'Offboarding...' : 'Offboard Employee'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Departments */}
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h3 className="text-xl font-semibold mb-4">Department Assignments</h3>
|
||||
|
||||
{status.departments.length === 0 ? (
|
||||
<p className="text-gray-500 italic">No departments assigned</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{status.departments.map((dept) => (
|
||||
<div
|
||||
key={dept.department_id}
|
||||
className="p-4 border border-gray-200 rounded-lg flex justify-between items-start"
|
||||
>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">{dept.department_name}</h4>
|
||||
<p className="text-gray-700">{dept.position}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Joined: {new Date(dept.joined_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
{getMembershipTypeBadge(dept.membership_type)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Roles */}
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h3 className="text-xl font-semibold mb-4">Role Assignments</h3>
|
||||
|
||||
{status.roles.length === 0 ? (
|
||||
<p className="text-gray-500 italic">No roles assigned</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{status.roles.map((role) => (
|
||||
<div
|
||||
key={role.role_id}
|
||||
className="p-4 border border-gray-200 rounded-lg"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h4 className="font-semibold text-gray-900">{role.role_name}</h4>
|
||||
<span className="px-2 py-1 bg-indigo-100 text-indigo-800 rounded text-xs font-mono">
|
||||
{role.role_code}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Assigned: {new Date(role.assigned_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h3 className="text-xl font-semibold mb-4">Enabled Services</h3>
|
||||
|
||||
{status.services.length === 0 ? (
|
||||
<p className="text-gray-500 italic">No services enabled</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{status.services.map((service) => (
|
||||
<div
|
||||
key={service.service_id}
|
||||
className="p-4 border border-gray-200 rounded-lg"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h4 className="font-semibold text-gray-900">{service.service_name}</h4>
|
||||
<span className="px-2 py-1 bg-green-100 text-green-800 rounded text-xs font-mono">
|
||||
{service.service_code}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(service.quota_gb || service.quota_mb) && (
|
||||
<p className="text-sm text-gray-700 mb-1">
|
||||
Quota:{' '}
|
||||
{service.quota_gb && `${service.quota_gb} GB`}
|
||||
{service.quota_mb && `${service.quota_mb} MB`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-500">
|
||||
Enabled: {new Date(service.enabled_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
448
frontend/components/OnboardingForm.tsx
Normal file
448
frontend/components/OnboardingForm.tsx
Normal file
@@ -0,0 +1,448 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { onboardingService } from '../services/onboarding.service'
|
||||
import type { OnboardingRequest, DepartmentAssignment } from '../types/onboarding'
|
||||
|
||||
interface FormData {
|
||||
resume_id: string
|
||||
keycloak_user_id: string
|
||||
keycloak_username: string
|
||||
hire_date: string
|
||||
storage_quota_gb: string
|
||||
email_quota_mb: string
|
||||
departments: DepartmentAssignment[]
|
||||
role_ids: string
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
resume_id?: string
|
||||
keycloak_user_id?: string
|
||||
keycloak_username?: string
|
||||
hire_date?: string
|
||||
storage_quota_gb?: string
|
||||
email_quota_mb?: string
|
||||
departments?: { [key: number]: { department_id?: string; position?: string } }
|
||||
}
|
||||
|
||||
export default function OnboardingForm() {
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
resume_id: '',
|
||||
keycloak_user_id: '',
|
||||
keycloak_username: '',
|
||||
hire_date: '',
|
||||
storage_quota_gb: '20',
|
||||
email_quota_mb: '5120',
|
||||
departments: [],
|
||||
role_ids: '',
|
||||
})
|
||||
|
||||
const [errors, setErrors] = useState<FormErrors>({})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [successMessage, setSuccessMessage] = useState('')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
|
||||
const validateUUID = (uuid: string): boolean => {
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
return uuidRegex.test(uuid)
|
||||
}
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: FormErrors = {}
|
||||
|
||||
// Required fields
|
||||
if (!formData.resume_id) {
|
||||
newErrors.resume_id = 'Resume ID is required'
|
||||
}
|
||||
|
||||
if (!formData.keycloak_user_id) {
|
||||
newErrors.keycloak_user_id = 'Keycloak User ID is required'
|
||||
} else if (!validateUUID(formData.keycloak_user_id)) {
|
||||
newErrors.keycloak_user_id = 'Invalid UUID format'
|
||||
}
|
||||
|
||||
if (!formData.keycloak_username) {
|
||||
newErrors.keycloak_username = 'Keycloak Username is required'
|
||||
}
|
||||
|
||||
if (!formData.hire_date) {
|
||||
newErrors.hire_date = 'Hire Date is required'
|
||||
}
|
||||
|
||||
// Validate storage quota
|
||||
const storageQuota = parseInt(formData.storage_quota_gb)
|
||||
if (formData.storage_quota_gb && storageQuota <= 0) {
|
||||
newErrors.storage_quota_gb = 'Storage quota must be positive'
|
||||
}
|
||||
|
||||
// Validate departments
|
||||
const departmentErrors: { [key: number]: { department_id?: string; position?: string } } = {}
|
||||
formData.departments.forEach((dept, index) => {
|
||||
const deptErrors: { department_id?: string; position?: string } = {}
|
||||
if (!dept.department_id) {
|
||||
deptErrors.department_id = 'Department ID is required'
|
||||
}
|
||||
if (!dept.position) {
|
||||
deptErrors.position = 'Position is required'
|
||||
}
|
||||
if (Object.keys(deptErrors).length > 0) {
|
||||
departmentErrors[index] = deptErrors
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(departmentErrors).length > 0) {
|
||||
newErrors.departments = departmentErrors
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setErrorMessage('')
|
||||
setSuccessMessage('')
|
||||
|
||||
try {
|
||||
const request: OnboardingRequest = {
|
||||
resume_id: parseInt(formData.resume_id),
|
||||
keycloak_user_id: formData.keycloak_user_id,
|
||||
keycloak_username: formData.keycloak_username,
|
||||
hire_date: formData.hire_date,
|
||||
departments: formData.departments,
|
||||
role_ids: formData.role_ids ? formData.role_ids.split(',').map((id) => parseInt(id.trim())) : [],
|
||||
storage_quota_gb: formData.storage_quota_gb ? parseInt(formData.storage_quota_gb) : undefined,
|
||||
email_quota_mb: formData.email_quota_mb ? parseInt(formData.email_quota_mb) : undefined,
|
||||
}
|
||||
|
||||
const response = await onboardingService.onboardEmployee(request)
|
||||
setSuccessMessage(response.message)
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
resume_id: '',
|
||||
keycloak_user_id: '',
|
||||
keycloak_username: '',
|
||||
hire_date: '',
|
||||
storage_quota_gb: '20',
|
||||
email_quota_mb: '5120',
|
||||
departments: [],
|
||||
role_ids: '',
|
||||
})
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.detail || 'An error occurred'
|
||||
setErrorMessage(message)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (field: keyof FormData, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
// Clear error for this field
|
||||
if (errors[field as keyof FormErrors]) {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev }
|
||||
delete newErrors[field as keyof FormErrors]
|
||||
return newErrors
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const addDepartment = () => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
departments: [
|
||||
...prev.departments,
|
||||
{
|
||||
department_id: 0,
|
||||
position: '',
|
||||
membership_type: 'permanent',
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
const removeDepartment = (index: number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
departments: prev.departments.filter((_, i) => i !== index),
|
||||
}))
|
||||
}
|
||||
|
||||
const updateDepartment = (index: number, field: keyof DepartmentAssignment, value: any) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
departments: prev.departments.map((dept, i) =>
|
||||
i === index ? { ...dept, [field]: value } : dept
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">Employee Onboarding</h1>
|
||||
|
||||
{successMessage && (
|
||||
<div className="mb-4 p-4 bg-green-100 text-green-700 rounded">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<div className="mb-4 p-4 bg-red-100 text-red-700 rounded">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic Information */}
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h2 className="text-xl font-semibold mb-4">Basic Information</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Resume ID */}
|
||||
<div>
|
||||
<label htmlFor="resume_id" className="block text-sm font-medium mb-1">
|
||||
Resume ID *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="resume_id"
|
||||
value={formData.resume_id}
|
||||
onChange={(e) => handleInputChange('resume_id', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded ${
|
||||
errors.resume_id ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
{errors.resume_id && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.resume_id}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keycloak User ID */}
|
||||
<div>
|
||||
<label htmlFor="keycloak_user_id" className="block text-sm font-medium mb-1">
|
||||
Keycloak User ID *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="keycloak_user_id"
|
||||
value={formData.keycloak_user_id}
|
||||
onChange={(e) => handleInputChange('keycloak_user_id', e.target.value)}
|
||||
placeholder="550e8400-e29b-41d4-a716-446655440000"
|
||||
className={`w-full px-3 py-2 border rounded ${
|
||||
errors.keycloak_user_id ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
{errors.keycloak_user_id && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.keycloak_user_id}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keycloak Username */}
|
||||
<div>
|
||||
<label htmlFor="keycloak_username" className="block text-sm font-medium mb-1">
|
||||
Keycloak Username *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="keycloak_username"
|
||||
value={formData.keycloak_username}
|
||||
onChange={(e) => handleInputChange('keycloak_username', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded ${
|
||||
errors.keycloak_username ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
{errors.keycloak_username && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.keycloak_username}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hire Date */}
|
||||
<div>
|
||||
<label htmlFor="hire_date" className="block text-sm font-medium mb-1">
|
||||
Hire Date *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="hire_date"
|
||||
value={formData.hire_date}
|
||||
onChange={(e) => handleInputChange('hire_date', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded ${
|
||||
errors.hire_date ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
{errors.hire_date && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.hire_date}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Storage Quota */}
|
||||
<div>
|
||||
<label htmlFor="storage_quota_gb" className="block text-sm font-medium mb-1">
|
||||
Storage Quota (GB)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="storage_quota_gb"
|
||||
value={formData.storage_quota_gb}
|
||||
onChange={(e) => handleInputChange('storage_quota_gb', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded ${
|
||||
errors.storage_quota_gb ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
{errors.storage_quota_gb && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.storage_quota_gb}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email Quota */}
|
||||
<div>
|
||||
<label htmlFor="email_quota_mb" className="block text-sm font-medium mb-1">
|
||||
Email Quota (MB)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="email_quota_mb"
|
||||
value={formData.email_quota_mb}
|
||||
onChange={(e) => handleInputChange('email_quota_mb', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Department Assignments */}
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold">Department Assignments</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addDepartment}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
Add Department
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formData.departments.map((dept, index) => (
|
||||
<div key={index} className="mb-4 p-4 border border-gray-200 rounded">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Department ID */}
|
||||
<div>
|
||||
<label htmlFor={`dept_id_${index}`} className="block text-sm font-medium mb-1">
|
||||
Department ID *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id={`dept_id_${index}`}
|
||||
value={dept.department_id || ''}
|
||||
onChange={(e) =>
|
||||
updateDepartment(index, 'department_id', parseInt(e.target.value) || 0)
|
||||
}
|
||||
className={`w-full px-3 py-2 border rounded ${
|
||||
errors.departments?.[index]?.department_id
|
||||
? 'border-red-500'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
{errors.departments?.[index]?.department_id && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{errors.departments[index].department_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Position */}
|
||||
<div>
|
||||
<label htmlFor={`position_${index}`} className="block text-sm font-medium mb-1">
|
||||
Position *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id={`position_${index}`}
|
||||
value={dept.position}
|
||||
onChange={(e) => updateDepartment(index, 'position', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded ${
|
||||
errors.departments?.[index]?.position ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
{errors.departments?.[index]?.position && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{errors.departments[index].position}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Membership Type */}
|
||||
<div>
|
||||
<label htmlFor={`membership_${index}`} className="block text-sm font-medium mb-1">
|
||||
Membership Type *
|
||||
</label>
|
||||
<select
|
||||
id={`membership_${index}`}
|
||||
value={dept.membership_type}
|
||||
onChange={(e) => updateDepartment(index, 'membership_type', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded"
|
||||
>
|
||||
<option value="permanent">Permanent</option>
|
||||
<option value="concurrent">Concurrent</option>
|
||||
<option value="temporary">Temporary</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeDepartment(index)}
|
||||
className="mt-2 px-3 py-1 bg-red-500 text-white rounded hover:bg-red-600 text-sm"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Role Assignments */}
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h2 className="text-xl font-semibold mb-4">Role Assignments</h2>
|
||||
<div>
|
||||
<label htmlFor="role_ids" className="block text-sm font-medium mb-1">
|
||||
Role IDs (comma-separated)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="role_ids"
|
||||
value={formData.role_ids}
|
||||
onChange={(e) => handleInputChange('role_ids', e.target.value)}
|
||||
placeholder="1, 2, 3"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={`px-6 py-3 rounded font-medium ${
|
||||
isSubmitting
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-green-500 hover:bg-green-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
435
frontend/components/TenantCreateForm.tsx
Normal file
435
frontend/components/TenantCreateForm.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
'use client'
|
||||
|
||||
import { useState, FormEvent } from 'react'
|
||||
import { tenantService } from '../services/tenant.service'
|
||||
import type { TenantCreateRequest, TenantCreateResponse } from '../types/tenant'
|
||||
|
||||
interface TenantCreateFormProps {
|
||||
onSuccess: (response: TenantCreateResponse) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
code: string
|
||||
name: string
|
||||
name_eng: string
|
||||
tax_id: string
|
||||
prefix: string
|
||||
tel: string
|
||||
add: string
|
||||
url: string
|
||||
plan_id: string
|
||||
max_users: number
|
||||
storage_quota_gb: number
|
||||
admin_username: string
|
||||
admin_email: string
|
||||
admin_name: string
|
||||
admin_temp_password: string
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
export default function TenantCreateForm({ onSuccess, onCancel }: TenantCreateFormProps) {
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
code: '',
|
||||
name: '',
|
||||
name_eng: '',
|
||||
tax_id: '',
|
||||
prefix: '',
|
||||
tel: '',
|
||||
add: '',
|
||||
url: '',
|
||||
plan_id: 'starter',
|
||||
max_users: 5,
|
||||
storage_quota_gb: 100,
|
||||
admin_username: '',
|
||||
admin_email: '',
|
||||
admin_name: '',
|
||||
admin_temp_password: '',
|
||||
})
|
||||
|
||||
const [errors, setErrors] = useState<FormErrors>({})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [apiError, setApiError] = useState<string | null>(null)
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: FormErrors = {}
|
||||
|
||||
// Required fields
|
||||
if (!formData.code.trim()) {
|
||||
newErrors.code = 'Tenant code is required'
|
||||
}
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Company name is required'
|
||||
}
|
||||
if (!formData.prefix.trim()) {
|
||||
newErrors.prefix = 'Employee prefix is required'
|
||||
}
|
||||
if (!formData.admin_username.trim()) {
|
||||
newErrors.admin_username = 'Admin username is required'
|
||||
}
|
||||
if (!formData.admin_email.trim()) {
|
||||
newErrors.admin_email = 'Admin email is required'
|
||||
} else if (!formData.admin_email.includes('@')) {
|
||||
// Email format validation (only if not empty)
|
||||
newErrors.admin_email = 'Invalid email format'
|
||||
}
|
||||
if (!formData.admin_name.trim()) {
|
||||
newErrors.admin_name = 'Admin name is required'
|
||||
}
|
||||
if (!formData.admin_temp_password.trim()) {
|
||||
newErrors.admin_temp_password = 'Admin password is required'
|
||||
} else if (formData.admin_temp_password.length < 8) {
|
||||
// Password length validation (only if not empty)
|
||||
newErrors.admin_temp_password = 'Password must be at least 8 characters'
|
||||
}
|
||||
|
||||
// Tax ID validation (8 digits if provided)
|
||||
if (formData.tax_id && !/^\d{8}$/.test(formData.tax_id)) {
|
||||
newErrors.tax_id = 'Tax ID must be 8 digits'
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setApiError(null)
|
||||
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const requestData: TenantCreateRequest = {
|
||||
code: formData.code,
|
||||
name: formData.name,
|
||||
name_eng: formData.name_eng || '',
|
||||
tax_id: formData.tax_id || undefined,
|
||||
prefix: formData.prefix,
|
||||
tel: formData.tel || '',
|
||||
add: formData.add || '',
|
||||
url: formData.url || '',
|
||||
plan_id: formData.plan_id,
|
||||
max_users: formData.max_users,
|
||||
storage_quota_gb: formData.storage_quota_gb,
|
||||
admin_username: formData.admin_username,
|
||||
admin_email: formData.admin_email,
|
||||
admin_name: formData.admin_name,
|
||||
admin_temp_password: formData.admin_temp_password,
|
||||
}
|
||||
|
||||
const response = await tenantService.createTenant(requestData)
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
code: '',
|
||||
name: '',
|
||||
name_eng: '',
|
||||
tax_id: '',
|
||||
prefix: '',
|
||||
tel: '',
|
||||
add: '',
|
||||
url: '',
|
||||
plan_id: 'starter',
|
||||
max_users: 5,
|
||||
storage_quota_gb: 100,
|
||||
admin_username: '',
|
||||
admin_email: '',
|
||||
admin_name: '',
|
||||
admin_temp_password: '',
|
||||
})
|
||||
|
||||
onSuccess(response)
|
||||
} catch (error: any) {
|
||||
const errorMessage =
|
||||
error?.response?.data?.detail || 'Failed to create tenant. Please try again.'
|
||||
setApiError(errorMessage)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (field: keyof FormData, value: string | number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}))
|
||||
// Clear error when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev }
|
||||
delete newErrors[field]
|
||||
return newErrors
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* API Error Display */}
|
||||
{apiError && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-red-700">{apiError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Basic Information Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Basic Information</h3>
|
||||
|
||||
<div>
|
||||
<label htmlFor="code" className="block text-sm font-medium text-gray-700">
|
||||
Tenant Code *
|
||||
</label>
|
||||
<input
|
||||
id="code"
|
||||
type="text"
|
||||
value={formData.code}
|
||||
onChange={(e) => handleChange('code', e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
{errors.code && <p className="mt-1 text-sm text-red-600">{errors.code}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
Company Name *
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-sm text-red-600">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="name_eng" className="block text-sm font-medium text-gray-700">
|
||||
Company Name (English)
|
||||
</label>
|
||||
<input
|
||||
id="name_eng"
|
||||
type="text"
|
||||
value={formData.name_eng}
|
||||
onChange={(e) => handleChange('name_eng', e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="tax_id" className="block text-sm font-medium text-gray-700">
|
||||
Tax ID
|
||||
</label>
|
||||
<input
|
||||
id="tax_id"
|
||||
type="text"
|
||||
value={formData.tax_id}
|
||||
onChange={(e) => handleChange('tax_id', e.target.value)}
|
||||
placeholder="8 digits"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
{errors.tax_id && <p className="mt-1 text-sm text-red-600">{errors.tax_id}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="prefix" className="block text-sm font-medium text-gray-700">
|
||||
Employee Prefix *
|
||||
</label>
|
||||
<input
|
||||
id="prefix"
|
||||
type="text"
|
||||
value={formData.prefix}
|
||||
onChange={(e) => handleChange('prefix', e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
{errors.prefix && <p className="mt-1 text-sm text-red-600">{errors.prefix}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Information Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Contact Information</h3>
|
||||
|
||||
<div>
|
||||
<label htmlFor="tel" className="block text-sm font-medium text-gray-700">
|
||||
Phone
|
||||
</label>
|
||||
<input
|
||||
id="tel"
|
||||
type="text"
|
||||
value={formData.tel}
|
||||
onChange={(e) => handleChange('tel', e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="add" className="block text-sm font-medium text-gray-700">
|
||||
Address
|
||||
</label>
|
||||
<textarea
|
||||
id="add"
|
||||
value={formData.add}
|
||||
onChange={(e) => handleChange('add', e.target.value)}
|
||||
rows={3}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="url" className="block text-sm font-medium text-gray-700">
|
||||
Website
|
||||
</label>
|
||||
<input
|
||||
id="url"
|
||||
type="text"
|
||||
value={formData.url}
|
||||
onChange={(e) => handleChange('url', e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plan Settings Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Plan Settings</h3>
|
||||
|
||||
<div>
|
||||
<label htmlFor="plan_id" className="block text-sm font-medium text-gray-700">
|
||||
Plan ID
|
||||
</label>
|
||||
<input
|
||||
id="plan_id"
|
||||
type="text"
|
||||
value={formData.plan_id}
|
||||
onChange={(e) => handleChange('plan_id', e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="max_users" className="block text-sm font-medium text-gray-700">
|
||||
Max Users
|
||||
</label>
|
||||
<input
|
||||
id="max_users"
|
||||
type="number"
|
||||
value={formData.max_users}
|
||||
onChange={(e) => handleChange('max_users', parseInt(e.target.value))}
|
||||
min="1"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="storage_quota_gb" className="block text-sm font-medium text-gray-700">
|
||||
Storage Quota (GB)
|
||||
</label>
|
||||
<input
|
||||
id="storage_quota_gb"
|
||||
type="number"
|
||||
value={formData.storage_quota_gb}
|
||||
onChange={(e) => handleChange('storage_quota_gb', parseInt(e.target.value))}
|
||||
min="1"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Account Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Admin Account</h3>
|
||||
|
||||
<div>
|
||||
<label htmlFor="admin_username" className="block text-sm font-medium text-gray-700">
|
||||
Admin Username *
|
||||
</label>
|
||||
<input
|
||||
id="admin_username"
|
||||
type="text"
|
||||
value={formData.admin_username}
|
||||
onChange={(e) => handleChange('admin_username', e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
{errors.admin_username && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.admin_username}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="admin_email" className="block text-sm font-medium text-gray-700">
|
||||
Admin Email *
|
||||
</label>
|
||||
<input
|
||||
id="admin_email"
|
||||
type="email"
|
||||
value={formData.admin_email}
|
||||
onChange={(e) => handleChange('admin_email', e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
{errors.admin_email && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.admin_email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="admin_name" className="block text-sm font-medium text-gray-700">
|
||||
Admin Name *
|
||||
</label>
|
||||
<input
|
||||
id="admin_name"
|
||||
type="text"
|
||||
value={formData.admin_name}
|
||||
onChange={(e) => handleChange('admin_name', e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
{errors.admin_name && <p className="mt-1 text-sm text-red-600">{errors.admin_name}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="admin_temp_password" className="block text-sm font-medium text-gray-700">
|
||||
Admin Password *
|
||||
</label>
|
||||
<input
|
||||
id="admin_temp_password"
|
||||
type="password"
|
||||
value={formData.admin_temp_password}
|
||||
onChange={(e) => handleChange('admin_temp_password', e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
{errors.admin_temp_password && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.admin_temp_password}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex justify-end space-x-3">
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm 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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm 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 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Creating...' : 'Create Tenant'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
137
frontend/components/TenantList.tsx
Normal file
137
frontend/components/TenantList.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { tenantService } from '../services/tenant.service'
|
||||
import type { Tenant } from '../types/tenant'
|
||||
|
||||
export default function TenantList() {
|
||||
const [tenants, setTenants] = useState<Tenant[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const loadTenants = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const response = await tenantService.getTenants()
|
||||
setTenants(response.items)
|
||||
} catch (err) {
|
||||
setError('Failed to load tenants')
|
||||
console.error('Error loading tenants:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadTenants()
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center p-8">
|
||||
<div className="text-gray-600">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-red-700">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (tenants.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<p>No tenants found</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getStatusBadgeClass = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case 'trial':
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
case 'suspended':
|
||||
return 'bg-yellow-100 text-yellow-800'
|
||||
case 'deleted':
|
||||
return 'bg-red-100 text-red-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
return status.charAt(0).toUpperCase() + status.slice(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<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">
|
||||
Code
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Initialization
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{tenants.map((tenant) => (
|
||||
<tr key={tenant.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{tenant.code}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{tenant.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusBadgeClass(
|
||||
tenant.status
|
||||
)}`}
|
||||
>
|
||||
{getStatusLabel(tenant.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{tenant.is_initialized ? (
|
||||
<span className="text-green-600">Initialized</span>
|
||||
) : (
|
||||
<span className="text-yellow-600">Not Initialized</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
className="text-indigo-600 hover:text-indigo-900"
|
||||
onClick={() => {
|
||||
// Navigate to tenant details
|
||||
console.log('View details for tenant:', tenant.id)
|
||||
}}
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
471
frontend/components/__tests__/EmployeeStatusCard.test.tsx
Normal file
471
frontend/components/__tests__/EmployeeStatusCard.test.tsx
Normal file
@@ -0,0 +1,471 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import EmployeeStatusCard from '../EmployeeStatusCard'
|
||||
import { onboardingService } from '../../services/onboarding.service'
|
||||
import type { EmployeeStatusResponse } from '../../types/onboarding'
|
||||
|
||||
// Mock the onboarding service
|
||||
vi.mock('../../services/onboarding.service', () => ({
|
||||
onboardingService: {
|
||||
getEmployeeStatus: vi.fn(),
|
||||
offboardEmployee: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock Next.js navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('EmployeeStatusCard', () => {
|
||||
// Mock window.confirm
|
||||
const originalConfirm = window.confirm
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock window.confirm to return true by default
|
||||
window.confirm = vi.fn(() => true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original confirm
|
||||
window.confirm = originalConfirm
|
||||
})
|
||||
|
||||
const mockEmployeeStatus: EmployeeStatusResponse = {
|
||||
employee: {
|
||||
tenant_id: 1,
|
||||
seq_no: 1,
|
||||
tenant_emp_code: 'PWD0001',
|
||||
name: '王明',
|
||||
keycloak_user_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
keycloak_username: 'wang.ming',
|
||||
hire_date: '2026-02-21',
|
||||
resign_date: null,
|
||||
employment_status: 'active',
|
||||
storage_quota_gb: 20,
|
||||
email_quota_mb: 5120,
|
||||
},
|
||||
departments: [
|
||||
{
|
||||
department_id: 9,
|
||||
department_name: '玄鐵風能',
|
||||
position: '資深工程師',
|
||||
membership_type: 'permanent',
|
||||
joined_at: '2026-02-21T10:00:00',
|
||||
},
|
||||
{
|
||||
department_id: 10,
|
||||
department_name: '國際碳權',
|
||||
position: '顧問',
|
||||
membership_type: 'concurrent',
|
||||
joined_at: '2026-02-21T10:00:00',
|
||||
},
|
||||
],
|
||||
roles: [
|
||||
{
|
||||
role_id: 1,
|
||||
role_name: 'HR管理員',
|
||||
role_code: 'HR_ADMIN',
|
||||
assigned_at: '2026-02-21T10:00:00',
|
||||
},
|
||||
{
|
||||
role_id: 2,
|
||||
role_name: '系統管理員',
|
||||
role_code: 'SYS_ADMIN',
|
||||
assigned_at: '2026-02-21T10:00:00',
|
||||
},
|
||||
],
|
||||
services: [
|
||||
{
|
||||
service_id: 1,
|
||||
service_name: '單一簽入',
|
||||
service_code: 'SSO',
|
||||
quota_gb: null,
|
||||
quota_mb: null,
|
||||
enabled_at: '2026-02-21T10:00:00',
|
||||
},
|
||||
{
|
||||
service_id: 2,
|
||||
service_name: '雲端硬碟',
|
||||
service_code: 'DRIVE',
|
||||
quota_gb: 20,
|
||||
quota_mb: null,
|
||||
enabled_at: '2026-02-21T10:00:00',
|
||||
},
|
||||
{
|
||||
service_id: 3,
|
||||
service_name: '電子郵件',
|
||||
service_code: 'EMAIL',
|
||||
quota_gb: null,
|
||||
quota_mb: 5120,
|
||||
enabled_at: '2026-02-21T10:00:00',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('should show loading spinner while fetching data', () => {
|
||||
vi.mocked(onboardingService.getEmployeeStatus).mockImplementation(
|
||||
() => new Promise(() => {}) // Never resolves
|
||||
)
|
||||
|
||||
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
||||
|
||||
expect(screen.getByText(/Loading/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Employee Basic Information', () => {
|
||||
it('should display employee basic information', async () => {
|
||||
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
||||
|
||||
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('王明')).toBeInTheDocument()
|
||||
expect(screen.getByText('PWD0001')).toBeInTheDocument()
|
||||
expect(screen.getByText('wang.ming')).toBeInTheDocument()
|
||||
expect(screen.getByText(/2026-02-21/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display employment status badge', async () => {
|
||||
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
||||
|
||||
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Active/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display storage and email quotas', async () => {
|
||||
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
||||
|
||||
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
||||
|
||||
await waitFor(() => {
|
||||
const storageQuotas = screen.getAllByText(/20 GB/i)
|
||||
expect(storageQuotas.length).toBeGreaterThan(0)
|
||||
|
||||
const emailQuotas = screen.getAllByText(/5120 MB/i)
|
||||
expect(emailQuotas.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should display Keycloak information', async () => {
|
||||
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
||||
|
||||
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/550e8400-e29b-41d4-a716-446655440000/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Department List', () => {
|
||||
it('should display all departments', async () => {
|
||||
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
||||
|
||||
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('玄鐵風能')).toBeInTheDocument()
|
||||
expect(screen.getByText('國際碳權')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display department positions', async () => {
|
||||
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
||||
|
||||
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('資深工程師')).toBeInTheDocument()
|
||||
expect(screen.getByText('顧問')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display membership types', async () => {
|
||||
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
||||
|
||||
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Permanent/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Concurrent/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show message when no departments assigned', async () => {
|
||||
const statusWithNoDepts: EmployeeStatusResponse = {
|
||||
...mockEmployeeStatus,
|
||||
departments: [],
|
||||
}
|
||||
|
||||
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(statusWithNoDepts)
|
||||
|
||||
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No departments assigned/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Role List', () => {
|
||||
it('should display all roles', async () => {
|
||||
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
||||
|
||||
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('HR管理員')).toBeInTheDocument()
|
||||
expect(screen.getByText('系統管理員')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display role codes', async () => {
|
||||
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
||||
|
||||
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('HR_ADMIN')).toBeInTheDocument()
|
||||
expect(screen.getByText('SYS_ADMIN')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show message when no roles assigned', async () => {
|
||||
const statusWithNoRoles: EmployeeStatusResponse = {
|
||||
...mockEmployeeStatus,
|
||||
roles: [],
|
||||
}
|
||||
|
||||
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(statusWithNoRoles)
|
||||
|
||||
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No roles assigned/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Service List', () => {
|
||||
it('should display all enabled services', async () => {
|
||||
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
||||
|
||||
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('單一簽入')).toBeInTheDocument()
|
||||
expect(screen.getByText('雲端硬碟')).toBeInTheDocument()
|
||||
expect(screen.getByText('電子郵件')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display service codes', async () => {
|
||||
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
||||
|
||||
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('SSO')).toBeInTheDocument()
|
||||
expect(screen.getByText('DRIVE')).toBeInTheDocument()
|
||||
expect(screen.getByText('EMAIL')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display service quotas when available', async () => {
|
||||
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
||||
|
||||
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
||||
|
||||
await waitFor(() => {
|
||||
// Drive quota
|
||||
const driveQuota = screen.getAllByText(/20 GB/i)
|
||||
expect(driveQuota.length).toBeGreaterThan(0)
|
||||
|
||||
// Email quota
|
||||
const emailQuota = screen.getAllByText(/5120 MB/i)
|
||||
expect(emailQuota.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should show message when no services enabled', async () => {
|
||||
const statusWithNoServices: EmployeeStatusResponse = {
|
||||
...mockEmployeeStatus,
|
||||
services: [],
|
||||
}
|
||||
|
||||
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(statusWithNoServices)
|
||||
|
||||
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No services enabled/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should display error message when API fails', async () => {
|
||||
const errorMessage = 'Employee not found'
|
||||
vi.mocked(onboardingService.getEmployeeStatus).mockRejectedValueOnce({
|
||||
response: {
|
||||
data: { detail: errorMessage },
|
||||
status: 404,
|
||||
},
|
||||
})
|
||||
|
||||
render(<EmployeeStatusCard tenantId={1} seqNo={999} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display generic error message for unknown errors', async () => {
|
||||
vi.mocked(onboardingService.getEmployeeStatus).mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Failed to load employee status/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Offboard Action', () => {
|
||||
it('should display offboard button for active employees', async () => {
|
||||
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
||||
|
||||
render(<EmployeeStatusCard tenantId={1} seqNo={1} showActions={true} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Offboard/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not display offboard button when showActions is false', async () => {
|
||||
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
||||
|
||||
render(<EmployeeStatusCard tenantId={1} seqNo={1} showActions={false} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: /Offboard/i })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call offboardEmployee when offboard button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
||||
vi.mocked(onboardingService.offboardEmployee).mockResolvedValueOnce({
|
||||
message: 'Employee offboarded successfully',
|
||||
employee: { tenant_emp_code: 'PWD0001', resign_date: '2026-02-21' },
|
||||
summary: { departments_removed: 2, roles_revoked: 2, services_disabled: 3 },
|
||||
})
|
||||
|
||||
render(<EmployeeStatusCard tenantId={1} seqNo={1} showActions={true} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Offboard/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const offboardButton = screen.getByRole('button', { name: /Offboard/i })
|
||||
await user.click(offboardButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onboardingService.offboardEmployee).toHaveBeenCalledWith(1, 1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should show success message after offboarding', async () => {
|
||||
const user = userEvent.setup()
|
||||
const offboardedStatus = {
|
||||
...mockEmployeeStatus,
|
||||
employee: { ...mockEmployeeStatus.employee, employment_status: 'resigned' as const, resign_date: '2026-02-21' },
|
||||
}
|
||||
|
||||
vi.mocked(onboardingService.getEmployeeStatus)
|
||||
.mockResolvedValueOnce(mockEmployeeStatus) // Initial load
|
||||
.mockResolvedValueOnce(offboardedStatus) // After offboard refresh
|
||||
|
||||
vi.mocked(onboardingService.offboardEmployee).mockResolvedValueOnce({
|
||||
message: 'Employee offboarded successfully',
|
||||
employee: { tenant_emp_code: 'PWD0001', resign_date: '2026-02-21' },
|
||||
summary: { departments_removed: 2, roles_revoked: 2, services_disabled: 3 },
|
||||
})
|
||||
|
||||
render(<EmployeeStatusCard tenantId={1} seqNo={1} showActions={true} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Offboard/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const offboardButton = screen.getByRole('button', { name: /Offboard/i })
|
||||
await user.click(offboardButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Employee offboarded successfully/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle offboard errors gracefully', async () => {
|
||||
const user = userEvent.setup()
|
||||
const errorMessage = 'Failed to offboard employee'
|
||||
|
||||
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
||||
vi.mocked(onboardingService.offboardEmployee).mockRejectedValueOnce({
|
||||
response: {
|
||||
data: { detail: errorMessage },
|
||||
status: 500,
|
||||
},
|
||||
})
|
||||
|
||||
render(<EmployeeStatusCard tenantId={1} seqNo={1} showActions={true} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Offboard/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const offboardButton = screen.getByRole('button', { name: /Offboard/i })
|
||||
await user.click(offboardButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Refresh Functionality', () => {
|
||||
it('should refresh employee status when refresh is called', async () => {
|
||||
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValue(mockEmployeeStatus)
|
||||
|
||||
const { rerender } = render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('王明')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(onboardingService.getEmployeeStatus).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Trigger refresh by rerendering with key change
|
||||
rerender(<EmployeeStatusCard tenantId={1} seqNo={1} key="refresh" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onboardingService.getEmployeeStatus).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
319
frontend/components/__tests__/OnboardingForm.test.tsx
Normal file
319
frontend/components/__tests__/OnboardingForm.test.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import OnboardingForm from '../OnboardingForm'
|
||||
import { onboardingService } from '../../services/onboarding.service'
|
||||
import type { OnboardingResponse } from '../../types/onboarding'
|
||||
|
||||
// Mock the onboarding service
|
||||
vi.mock('../../services/onboarding.service', () => ({
|
||||
onboardingService: {
|
||||
onboardEmployee: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock Next.js navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('OnboardingForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render all required fields', () => {
|
||||
render(<OnboardingForm />)
|
||||
|
||||
// 必填欄位
|
||||
expect(screen.getByLabelText(/Resume ID/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Keycloak User ID/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Keycloak Username/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Hire Date/i)).toBeInTheDocument()
|
||||
|
||||
// 選填欄位
|
||||
expect(screen.getByLabelText(/Storage Quota \(GB\)/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Email Quota \(MB\)/i)).toBeInTheDocument()
|
||||
|
||||
// 提交按鈕
|
||||
expect(screen.getByRole('button', { name: /Submit/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render department assignment section', () => {
|
||||
render(<OnboardingForm />)
|
||||
|
||||
expect(screen.getByText(/Department Assignments/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Add Department/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render role assignment section', () => {
|
||||
render(<OnboardingForm />)
|
||||
|
||||
expect(screen.getByText(/Role Assignments/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Validation', () => {
|
||||
it('should show validation errors for required fields', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<OnboardingForm />)
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Submit/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Resume ID is required/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Keycloak User ID is required/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Keycloak Username is required/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Hire Date is required/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should validate Keycloak User ID format (UUID)', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<OnboardingForm />)
|
||||
|
||||
const keycloakUserIdInput = screen.getByLabelText(/Keycloak User ID/i)
|
||||
await user.type(keycloakUserIdInput, 'invalid-uuid')
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Submit/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Invalid UUID format/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should validate storage quota is a positive number', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<OnboardingForm />)
|
||||
|
||||
const storageQuotaInput = screen.getByLabelText(/Storage Quota \(GB\)/i)
|
||||
await user.clear(storageQuotaInput)
|
||||
await user.type(storageQuotaInput, '-10')
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Submit/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Storage quota must be positive/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Department Assignment', () => {
|
||||
it('should add a new department assignment', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<OnboardingForm />)
|
||||
|
||||
const addButton = screen.getByRole('button', { name: /Add Department/i })
|
||||
await user.click(addButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Department ID/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Position/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Membership Type/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove a department assignment', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<OnboardingForm />)
|
||||
|
||||
// 添加部門
|
||||
const addButton = screen.getByRole('button', { name: /Add Department/i })
|
||||
await user.click(addButton)
|
||||
|
||||
// 刪除部門
|
||||
const removeButton = screen.getByRole('button', { name: /Remove/i })
|
||||
await user.click(removeButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText(/Department ID/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should validate department assignment fields', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<OnboardingForm />)
|
||||
|
||||
const addButton = screen.getByRole('button', { name: /Add Department/i })
|
||||
await user.click(addButton)
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Submit/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Department ID is required/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Position is required/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('should submit form successfully with valid data', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockResponse: OnboardingResponse = {
|
||||
message: 'Employee onboarded successfully',
|
||||
employee: {
|
||||
tenant_id: 1,
|
||||
seq_no: 1,
|
||||
tenant_emp_code: 'PWD0001',
|
||||
keycloak_user_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
keycloak_username: 'wang.ming',
|
||||
name: '王明',
|
||||
hire_date: '2026-02-21',
|
||||
},
|
||||
summary: {
|
||||
departments_assigned: 1,
|
||||
roles_assigned: 2,
|
||||
services_enabled: 5,
|
||||
},
|
||||
}
|
||||
|
||||
vi.mocked(onboardingService.onboardEmployee).mockResolvedValueOnce(mockResponse)
|
||||
|
||||
render(<OnboardingForm />)
|
||||
|
||||
// 填寫表單
|
||||
await user.type(screen.getByLabelText(/Resume ID/i), '1')
|
||||
await user.type(
|
||||
screen.getByLabelText(/Keycloak User ID/i),
|
||||
'550e8400-e29b-41d4-a716-446655440000'
|
||||
)
|
||||
await user.type(screen.getByLabelText(/Keycloak Username/i), 'wang.ming')
|
||||
await user.type(screen.getByLabelText(/Hire Date/i), '2026-02-21')
|
||||
|
||||
// 提交表單
|
||||
const submitButton = screen.getByRole('button', { name: /Submit/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onboardingService.onboardEmployee).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resume_id: 1,
|
||||
keycloak_user_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
keycloak_username: 'wang.ming',
|
||||
hire_date: '2026-02-21',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
// 驗證成功訊息
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Employee onboarded successfully/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
const user = userEvent.setup()
|
||||
const errorMessage = 'Resume ID not found'
|
||||
|
||||
vi.mocked(onboardingService.onboardEmployee).mockRejectedValueOnce({
|
||||
response: {
|
||||
data: { detail: errorMessage },
|
||||
status: 404,
|
||||
},
|
||||
})
|
||||
|
||||
render(<OnboardingForm />)
|
||||
|
||||
// 填寫表單
|
||||
await user.type(screen.getByLabelText(/Resume ID/i), '999')
|
||||
await user.type(
|
||||
screen.getByLabelText(/Keycloak User ID/i),
|
||||
'550e8400-e29b-41d4-a716-446655440000'
|
||||
)
|
||||
await user.type(screen.getByLabelText(/Keycloak Username/i), 'test.user')
|
||||
await user.type(screen.getByLabelText(/Hire Date/i), '2026-02-21')
|
||||
|
||||
// 提交表單
|
||||
const submitButton = screen.getByRole('button', { name: /Submit/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show loading state during submission', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
// 模擬延遲的 API 回應
|
||||
vi.mocked(onboardingService.onboardEmployee).mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(resolve, 100))
|
||||
)
|
||||
|
||||
render(<OnboardingForm />)
|
||||
|
||||
// 填寫表單
|
||||
await user.type(screen.getByLabelText(/Resume ID/i), '1')
|
||||
await user.type(
|
||||
screen.getByLabelText(/Keycloak User ID/i),
|
||||
'550e8400-e29b-41d4-a716-446655440000'
|
||||
)
|
||||
await user.type(screen.getByLabelText(/Keycloak Username/i), 'wang.ming')
|
||||
await user.type(screen.getByLabelText(/Hire Date/i), '2026-02-21')
|
||||
|
||||
// 提交表單
|
||||
const submitButton = screen.getByRole('button', { name: /Submit/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
// 驗證載入狀態
|
||||
expect(screen.getByText(/Submitting.../i)).toBeInTheDocument()
|
||||
expect(submitButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should reset form after successful submission', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockResponse: OnboardingResponse = {
|
||||
message: 'Employee onboarded successfully',
|
||||
employee: {
|
||||
tenant_id: 1,
|
||||
seq_no: 1,
|
||||
tenant_emp_code: 'PWD0001',
|
||||
keycloak_user_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
keycloak_username: 'wang.ming',
|
||||
name: '王明',
|
||||
hire_date: '2026-02-21',
|
||||
},
|
||||
summary: {
|
||||
departments_assigned: 0,
|
||||
roles_assigned: 0,
|
||||
services_enabled: 5,
|
||||
},
|
||||
}
|
||||
|
||||
vi.mocked(onboardingService.onboardEmployee).mockResolvedValueOnce(mockResponse)
|
||||
|
||||
render(<OnboardingForm />)
|
||||
|
||||
// 填寫表單
|
||||
const resumeIdInput = screen.getByLabelText(/Resume ID/i) as HTMLInputElement
|
||||
await user.type(resumeIdInput, '1')
|
||||
await user.type(
|
||||
screen.getByLabelText(/Keycloak User ID/i),
|
||||
'550e8400-e29b-41d4-a716-446655440000'
|
||||
)
|
||||
await user.type(screen.getByLabelText(/Keycloak Username/i), 'wang.ming')
|
||||
await user.type(screen.getByLabelText(/Hire Date/i), '2026-02-21')
|
||||
|
||||
// 提交表單
|
||||
const submitButton = screen.getByRole('button', { name: /Submit/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Employee onboarded successfully/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// 驗證表單已重置
|
||||
await waitFor(() => {
|
||||
expect(resumeIdInput.value).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
345
frontend/components/__tests__/TenantCreateForm.test.tsx
Normal file
345
frontend/components/__tests__/TenantCreateForm.test.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { tenantService } from '../../services/tenant.service'
|
||||
import TenantCreateForm from '../TenantCreateForm'
|
||||
|
||||
// Mock tenant service
|
||||
vi.mock('../../services/tenant.service', () => ({
|
||||
tenantService: {
|
||||
createTenant: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('TenantCreateForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Form Rendering', () => {
|
||||
it('should render all required form fields', () => {
|
||||
// Act
|
||||
render(<TenantCreateForm onSuccess={vi.fn()} onCancel={vi.fn()} />)
|
||||
|
||||
// Assert - Basic Info Section
|
||||
expect(screen.getByLabelText(/^tenant code/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/^company name \*$/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/company name \(english\)/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/^tax id$/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/employee prefix/i)).toBeInTheDocument()
|
||||
|
||||
// Assert - Contact Info Section
|
||||
expect(screen.getByLabelText(/phone/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/address/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/website/i)).toBeInTheDocument()
|
||||
|
||||
// Assert - Plan Settings Section
|
||||
expect(screen.getByLabelText(/plan id/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/max users/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/storage quota/i)).toBeInTheDocument()
|
||||
|
||||
// Assert - Admin Account Section
|
||||
expect(screen.getByLabelText(/admin username/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/admin email/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/admin name/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/admin password/i)).toBeInTheDocument()
|
||||
|
||||
// Assert - Buttons
|
||||
expect(screen.getByRole('button', { name: /create tenant/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have default values for plan settings', () => {
|
||||
// Act
|
||||
render(<TenantCreateForm onSuccess={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
const planIdInput = screen.getByLabelText(/plan id/i) as HTMLInputElement
|
||||
const maxUsersInput = screen.getByLabelText(/max users/i) as HTMLInputElement
|
||||
const storageQuotaInput = screen.getByLabelText(/storage quota/i) as HTMLInputElement
|
||||
|
||||
expect(planIdInput.value).toBe('starter')
|
||||
expect(maxUsersInput.value).toBe('5')
|
||||
expect(storageQuotaInput.value).toBe('100')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Validation', () => {
|
||||
it('should show error when tenant code is empty', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
render(<TenantCreateForm onSuccess={vi.fn()} />)
|
||||
|
||||
// Act
|
||||
const submitButton = screen.getByRole('button', { name: /create tenant/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/tenant code is required/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error when company name is empty', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
render(<TenantCreateForm onSuccess={vi.fn()} />)
|
||||
|
||||
// Act
|
||||
const submitButton = screen.getByRole('button', { name: /create tenant/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/company name is required/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should validate tax id format (8 digits)', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
render(<TenantCreateForm onSuccess={vi.fn()} />)
|
||||
|
||||
// Act
|
||||
const taxIdInput = screen.getByLabelText(/tax id/i)
|
||||
await user.type(taxIdInput, '123') // Invalid: less than 8 digits
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /create tenant/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/tax id must be 8 digits/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it.skip('should validate email format', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
render(<TenantCreateForm onSuccess={vi.fn()} />)
|
||||
|
||||
// Act - Fill required fields first
|
||||
await user.type(screen.getByLabelText(/^tenant code/i), 'TEST')
|
||||
await user.type(screen.getByLabelText(/^company name \*$/i), '測試公司')
|
||||
await user.type(screen.getByLabelText(/employee prefix/i), 'T')
|
||||
await user.type(screen.getByLabelText(/admin username/i), 'admin')
|
||||
await user.type(screen.getByLabelText(/admin email/i), 'invalidemail') // Missing @
|
||||
await user.type(screen.getByLabelText(/admin name/i), 'Admin User')
|
||||
await user.type(screen.getByLabelText(/admin password/i), 'TempPass123!')
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /create tenant/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const errorElement = screen.queryByText(/invalid email format/i)
|
||||
expect(errorElement).toBeInTheDocument()
|
||||
}, { timeout: 3000 })
|
||||
})
|
||||
|
||||
it('should validate password length (minimum 8 characters)', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
render(<TenantCreateForm onSuccess={vi.fn()} />)
|
||||
|
||||
// Act
|
||||
const passwordInput = screen.getByLabelText(/admin password/i)
|
||||
await user.type(passwordInput, '12345') // Less than 8 characters
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /create tenant/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/password must be at least 8 characters/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should validate employee prefix is required', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
render(<TenantCreateForm onSuccess={vi.fn()} />)
|
||||
|
||||
// Act
|
||||
const submitButton = screen.getByRole('button', { name: /create tenant/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/employee prefix is required/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('should submit form with valid data', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const onSuccess = vi.fn()
|
||||
const mockResponse = {
|
||||
message: 'Tenant created successfully',
|
||||
tenant: {
|
||||
id: 1,
|
||||
code: 'TEST',
|
||||
name: '測試公司',
|
||||
keycloak_realm: 'porscheworld-test',
|
||||
},
|
||||
admin_user: {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'admin@test.com',
|
||||
},
|
||||
keycloak_realm: 'porscheworld-test',
|
||||
temporary_password: 'TempPass123!',
|
||||
}
|
||||
|
||||
vi.mocked(tenantService.createTenant).mockResolvedValueOnce(mockResponse)
|
||||
|
||||
render(<TenantCreateForm onSuccess={onSuccess} />)
|
||||
|
||||
// Act - Fill in form
|
||||
await user.type(screen.getByLabelText(/tenant code/i), 'TEST')
|
||||
await user.type(screen.getByLabelText(/^company name \*$/i), '測試公司')
|
||||
await user.type(screen.getByLabelText(/tax id/i), '12345678')
|
||||
await user.type(screen.getByLabelText(/employee prefix/i), 'T')
|
||||
await user.type(screen.getByLabelText(/admin username/i), 'admin')
|
||||
await user.type(screen.getByLabelText(/admin email/i), 'admin@test.com')
|
||||
await user.type(screen.getByLabelText(/admin name/i), 'Admin User')
|
||||
await user.type(screen.getByLabelText(/admin password/i), 'TempPass123!')
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /create tenant/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(tenantService.createTenant).toHaveBeenCalledWith({
|
||||
code: 'TEST',
|
||||
name: '測試公司',
|
||||
name_eng: '',
|
||||
tax_id: '12345678',
|
||||
prefix: 'T',
|
||||
tel: '',
|
||||
add: '',
|
||||
url: '',
|
||||
plan_id: 'starter',
|
||||
max_users: 5,
|
||||
storage_quota_gb: 100,
|
||||
admin_username: 'admin',
|
||||
admin_email: 'admin@test.com',
|
||||
admin_name: 'Admin User',
|
||||
admin_temp_password: 'TempPass123!',
|
||||
})
|
||||
expect(onSuccess).toHaveBeenCalledWith(mockResponse)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle API error gracefully', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(tenantService.createTenant).mockRejectedValueOnce({
|
||||
response: {
|
||||
data: {
|
||||
detail: "Tenant code 'TEST' already exists",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
render(<TenantCreateForm onSuccess={vi.fn()} />)
|
||||
|
||||
// Act - Fill in minimum required fields
|
||||
await user.type(screen.getByLabelText(/tenant code/i), 'TEST')
|
||||
await user.type(screen.getByLabelText(/^company name \*$/i), '測試公司')
|
||||
await user.type(screen.getByLabelText(/employee prefix/i), 'T')
|
||||
await user.type(screen.getByLabelText(/admin username/i), 'admin')
|
||||
await user.type(screen.getByLabelText(/admin email/i), 'admin@test.com')
|
||||
await user.type(screen.getByLabelText(/admin name/i), 'Admin User')
|
||||
await user.type(screen.getByLabelText(/admin password/i), 'TempPass123!')
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /create tenant/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/tenant code 'TEST' already exists/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show loading state during submission', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(tenantService.createTenant).mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(resolve, 100))
|
||||
)
|
||||
|
||||
render(<TenantCreateForm onSuccess={vi.fn()} />)
|
||||
|
||||
// Act - Fill in minimum required fields
|
||||
await user.type(screen.getByLabelText(/tenant code/i), 'TEST')
|
||||
await user.type(screen.getByLabelText(/^company name \*$/i), '測試公司')
|
||||
await user.type(screen.getByLabelText(/employee prefix/i), 'T')
|
||||
await user.type(screen.getByLabelText(/admin username/i), 'admin')
|
||||
await user.type(screen.getByLabelText(/admin email/i), 'admin@test.com')
|
||||
await user.type(screen.getByLabelText(/admin name/i), 'Admin User')
|
||||
await user.type(screen.getByLabelText(/admin password/i), 'TempPass123!')
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /create tenant/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/creating/i)).toBeInTheDocument()
|
||||
expect(submitButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should reset form after successful submission', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const mockResponse = {
|
||||
message: 'Tenant created successfully',
|
||||
tenant: { id: 1, code: 'TEST', name: '測試公司' },
|
||||
admin_user: { id: 1, username: 'admin' },
|
||||
keycloak_realm: 'porscheworld-test',
|
||||
temporary_password: 'TempPass123!',
|
||||
}
|
||||
|
||||
vi.mocked(tenantService.createTenant).mockResolvedValueOnce(mockResponse)
|
||||
|
||||
render(<TenantCreateForm onSuccess={vi.fn()} />)
|
||||
|
||||
// Act - Fill and submit form
|
||||
await user.type(screen.getByLabelText(/tenant code/i), 'TEST')
|
||||
await user.type(screen.getByLabelText(/^company name \*$/i), '測試公司')
|
||||
await user.type(screen.getByLabelText(/employee prefix/i), 'T')
|
||||
await user.type(screen.getByLabelText(/admin username/i), 'admin')
|
||||
await user.type(screen.getByLabelText(/admin email/i), 'admin@test.com')
|
||||
await user.type(screen.getByLabelText(/admin name/i), 'Admin User')
|
||||
await user.type(screen.getByLabelText(/admin password/i), 'TempPass123!')
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /create tenant/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
// Assert - Form should be reset
|
||||
await waitFor(() => {
|
||||
const codeInput = screen.getByLabelText(/tenant code/i) as HTMLInputElement
|
||||
const nameInput = screen.getByLabelText(/^company name \*$/i) as HTMLInputElement
|
||||
expect(codeInput.value).toBe('')
|
||||
expect(nameInput.value).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cancel Functionality', () => {
|
||||
it('should call onCancel when cancel button is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const onCancel = vi.fn()
|
||||
render(<TenantCreateForm onSuccess={vi.fn()} onCancel={onCancel} />)
|
||||
|
||||
// Act
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i })
|
||||
await user.click(cancelButton)
|
||||
|
||||
// Assert
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
268
frontend/components/__tests__/TenantList.test.tsx
Normal file
268
frontend/components/__tests__/TenantList.test.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { tenantService } from '../../services/tenant.service'
|
||||
import TenantList from '../TenantList'
|
||||
|
||||
// Mock tenant service
|
||||
vi.mock('../../services/tenant.service', () => ({
|
||||
tenantService: {
|
||||
getTenants: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('TenantList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('should show loading spinner while fetching tenants', () => {
|
||||
// Arrange
|
||||
vi.mocked(tenantService.getTenants).mockImplementation(
|
||||
() => new Promise(() => {}) // Never resolves
|
||||
)
|
||||
|
||||
// Act
|
||||
render(<TenantList />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tenant List Display', () => {
|
||||
it('should display all tenants', async () => {
|
||||
// Arrange
|
||||
const mockTenants = [
|
||||
{
|
||||
id: 1,
|
||||
code: 'PWD',
|
||||
name: '匠耘營造股份有限公司',
|
||||
keycloak_realm: 'porscheworld-pwd',
|
||||
status: 'active',
|
||||
is_initialized: true,
|
||||
max_users: 50,
|
||||
created_at: '2026-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
code: 'TEST',
|
||||
name: '測試公司',
|
||||
keycloak_realm: 'porscheworld-test',
|
||||
status: 'trial',
|
||||
is_initialized: false,
|
||||
max_users: 10,
|
||||
created_at: '2026-02-01T00:00:00',
|
||||
},
|
||||
]
|
||||
|
||||
vi.mocked(tenantService.getTenants).mockResolvedValueOnce({
|
||||
total: 2,
|
||||
items: mockTenants,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<TenantList />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('匠耘營造股份有限公司')).toBeInTheDocument()
|
||||
expect(screen.getByText('測試公司')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display tenant codes', async () => {
|
||||
// Arrange
|
||||
const mockTenants = [
|
||||
{
|
||||
id: 1,
|
||||
code: 'PWD',
|
||||
name: '匠耘營造',
|
||||
keycloak_realm: 'porscheworld-pwd',
|
||||
status: 'active',
|
||||
is_initialized: true,
|
||||
max_users: 50,
|
||||
created_at: '2026-01-01T00:00:00',
|
||||
},
|
||||
]
|
||||
|
||||
vi.mocked(tenantService.getTenants).mockResolvedValueOnce({
|
||||
total: 1,
|
||||
items: mockTenants,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<TenantList />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('PWD')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display tenant status badges', async () => {
|
||||
// Arrange
|
||||
const mockTenants = [
|
||||
{
|
||||
id: 1,
|
||||
code: 'PWD',
|
||||
name: '匠耘營造',
|
||||
keycloak_realm: 'porscheworld-pwd',
|
||||
status: 'active',
|
||||
is_initialized: true,
|
||||
max_users: 50,
|
||||
created_at: '2026-01-01T00:00:00',
|
||||
},
|
||||
]
|
||||
|
||||
vi.mocked(tenantService.getTenants).mockResolvedValueOnce({
|
||||
total: 1,
|
||||
items: mockTenants,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<TenantList />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Active')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display initialization status', async () => {
|
||||
// Arrange
|
||||
const mockTenants = [
|
||||
{
|
||||
id: 1,
|
||||
code: 'INIT',
|
||||
name: '已初始化公司',
|
||||
keycloak_realm: 'porscheworld-init',
|
||||
status: 'active',
|
||||
is_initialized: true,
|
||||
max_users: 50,
|
||||
created_at: '2026-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
code: 'NOINIT',
|
||||
name: '未初始化公司',
|
||||
keycloak_realm: 'porscheworld-noinit',
|
||||
status: 'trial',
|
||||
is_initialized: false,
|
||||
max_users: 10,
|
||||
created_at: '2026-02-01T00:00:00',
|
||||
},
|
||||
]
|
||||
|
||||
vi.mocked(tenantService.getTenants).mockResolvedValueOnce({
|
||||
total: 2,
|
||||
items: mockTenants,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<TenantList />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const initialized = screen.getAllByText(/initialized/i)
|
||||
expect(initialized.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should show empty state when no tenants exist', async () => {
|
||||
// Arrange
|
||||
vi.mocked(tenantService.getTenants).mockResolvedValueOnce({
|
||||
total: 0,
|
||||
items: [],
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<TenantList />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no tenants found/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should display error message when API fails', async () => {
|
||||
// Arrange
|
||||
vi.mocked(tenantService.getTenants).mockRejectedValueOnce(
|
||||
new Error('Network error')
|
||||
)
|
||||
|
||||
// Act
|
||||
render(<TenantList />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/failed to load tenants/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tenant Actions', () => {
|
||||
it('should display view details button for each tenant', async () => {
|
||||
// Arrange
|
||||
const mockTenants = [
|
||||
{
|
||||
id: 1,
|
||||
code: 'PWD',
|
||||
name: '匠耘營造',
|
||||
keycloak_realm: 'porscheworld-pwd',
|
||||
status: 'active',
|
||||
is_initialized: true,
|
||||
max_users: 50,
|
||||
created_at: '2026-01-01T00:00:00',
|
||||
},
|
||||
]
|
||||
|
||||
vi.mocked(tenantService.getTenants).mockResolvedValueOnce({
|
||||
total: 1,
|
||||
items: mockTenants,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<TenantList />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/view details/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Initial Load', () => {
|
||||
it('should call getTenants API on mount', async () => {
|
||||
// Arrange
|
||||
const mockTenants = [
|
||||
{
|
||||
id: 1,
|
||||
code: 'PWD',
|
||||
name: '匠耘營造',
|
||||
keycloak_realm: 'porscheworld-pwd',
|
||||
status: 'active',
|
||||
is_initialized: true,
|
||||
max_users: 50,
|
||||
created_at: '2026-01-01T00:00:00',
|
||||
},
|
||||
]
|
||||
|
||||
vi.mocked(tenantService.getTenants).mockResolvedValueOnce({
|
||||
total: 1,
|
||||
items: mockTenants,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<TenantList />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(tenantService.getTenants).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByText('匠耘營造')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
32
frontend/components/auth/session-provider.tsx
Normal file
32
frontend/components/auth/session-provider.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 認證 Session Provider
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { SessionProvider as NextAuthSessionProvider, useSession, signOut } from 'next-auth/react'
|
||||
|
||||
/**
|
||||
* Session Monitor - 監控 Keycloak token refresh 狀態
|
||||
*/
|
||||
function SessionMonitor({ children }: { children: React.ReactNode }) {
|
||||
const { data: session, status } = useSession()
|
||||
|
||||
useEffect(() => {
|
||||
// 如果 session 有 RefreshTokenError,強制登出
|
||||
if (status === 'authenticated' && (session as any)?.error === 'RefreshTokenError') {
|
||||
console.error('[SessionMonitor] Keycloak refresh token expired - forcing logout')
|
||||
signOut({ callbackUrl: '/auth/signin' })
|
||||
}
|
||||
}, [session, status])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextAuthSessionProvider>
|
||||
<SessionMonitor>{children}</SessionMonitor>
|
||||
</NextAuthSessionProvider>
|
||||
)
|
||||
}
|
||||
397
frontend/components/employees/email-accounts-tab.tsx
Normal file
397
frontend/components/employees/email-accounts-tab.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
|
||||
interface EmailAccount {
|
||||
id: number
|
||||
email_address: string
|
||||
quota_mb: number
|
||||
used_mb?: number
|
||||
forward_to?: string
|
||||
auto_reply?: string
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface EmailAccountsTabProps {
|
||||
employeeId: number
|
||||
// Phase 2.4: 員工主要身份資訊
|
||||
primaryIdentity?: {
|
||||
email_domain: string
|
||||
business_unit_name: string
|
||||
email_quota_mb: number
|
||||
}
|
||||
}
|
||||
|
||||
export default function EmailAccountsTab({ employeeId, primaryIdentity }: EmailAccountsTabProps) {
|
||||
const { data: session } = useSession()
|
||||
const [emailAccounts, setEmailAccounts] = useState<EmailAccount[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
|
||||
// Phase 2.4: 根據主要身份設定預設網域和配額
|
||||
const defaultDomain = primaryIdentity?.email_domain || 'porscheworld.tw'
|
||||
const defaultQuota = primaryIdentity?.email_quota_mb || 2048
|
||||
|
||||
const [newEmail, setNewEmail] = useState({
|
||||
emailPrefix: '',
|
||||
domain: defaultDomain,
|
||||
quota_mb: defaultQuota,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchEmailAccounts()
|
||||
}, [employeeId])
|
||||
|
||||
const fetchEmailAccounts = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL}/email-accounts/?employee_id=${employeeId}`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('無法載入郵件帳號')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setEmailAccounts(data.items || [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '載入失敗')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateEmailAccount = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!newEmail.emailPrefix.trim()) {
|
||||
alert('請輸入郵件帳號')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const emailAddress = `${newEmail.emailPrefix}@${newEmail.domain}`
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL}/email-accounts/`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
employee_id: employeeId,
|
||||
email_address: emailAddress,
|
||||
quota_mb: newEmail.quota_mb,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.detail || '創建郵件帳號失敗')
|
||||
}
|
||||
|
||||
alert('郵件帳號創建成功!')
|
||||
setShowAddForm(false)
|
||||
setNewEmail({ emailPrefix: '', domain: 'porscheworld.tw', quota_mb: 2048 })
|
||||
fetchEmailAccounts()
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : '操作失敗')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleActive = async (accountId: number, currentStatus: boolean) => {
|
||||
if (!confirm(`確定要${currentStatus ? '停用' : '啟用'}此郵件帳號嗎?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL}/email-accounts/${accountId}`,
|
||||
{
|
||||
method: currentStatus ? 'DELETE' : 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: !currentStatus ? JSON.stringify({ is_active: true }) : undefined,
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('操作失敗')
|
||||
}
|
||||
|
||||
alert(`郵件帳號已${currentStatus ? '停用' : '啟用'}`)
|
||||
fetchEmailAccounts()
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : '操作失敗')
|
||||
}
|
||||
}
|
||||
|
||||
const formatQuotaUsage = (used: number, total: number) => {
|
||||
const percentage = (used / total) * 100
|
||||
return {
|
||||
percentage: Math.round(percentage),
|
||||
usedGB: (used / 1024).toFixed(2),
|
||||
totalGB: (total / 1024).toFixed(2),
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-gray-600">載入中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 標題與新增按鈕 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">郵件帳號列表</h3>
|
||||
<button
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{showAddForm ? '取消' : '+ 新增郵件帳號'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 新增表單 */}
|
||||
{showAddForm && (
|
||||
<form
|
||||
onSubmit={handleCreateEmailAccount}
|
||||
className="bg-blue-50 border border-blue-200 rounded-lg p-6"
|
||||
>
|
||||
<h4 className="text-md font-semibold text-gray-900 mb-4">
|
||||
新增郵件帳號
|
||||
</h4>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
郵件帳號 *
|
||||
{primaryIdentity && (
|
||||
<span className="ml-2 text-xs text-blue-600">
|
||||
(預設使用 {primaryIdentity.business_unit_name} 的網域)
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newEmail.emailPrefix}
|
||||
onChange={(e) =>
|
||||
setNewEmail({ ...newEmail, emailPrefix: e.target.value })
|
||||
}
|
||||
placeholder="例如: john.doe"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
<span className="flex items-center px-3 py-2 bg-gray-100 border border-gray-300 rounded-lg">
|
||||
@
|
||||
</span>
|
||||
<select
|
||||
value={newEmail.domain}
|
||||
onChange={(e) =>
|
||||
setNewEmail({ ...newEmail, domain: e.target.value })
|
||||
}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="porscheworld.tw">porscheworld.tw</option>
|
||||
<option value="lab.taipei">lab.taipei</option>
|
||||
<option value="ease.taipei">ease.taipei</option>
|
||||
</select>
|
||||
</div>
|
||||
{primaryIdentity && newEmail.domain !== primaryIdentity.email_domain && (
|
||||
<p className="mt-1 text-xs text-amber-600">
|
||||
⚠️ 注意:您選擇的網域與員工所屬事業部不同
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
配額 (MB) *
|
||||
</label>
|
||||
<select
|
||||
value={newEmail.quota_mb}
|
||||
onChange={(e) =>
|
||||
setNewEmail({ ...newEmail, quota_mb: parseInt(e.target.value) })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value={1024}>1 GB (一般員工)</option>
|
||||
<option value={2048}>2 GB (標準)</option>
|
||||
<option value={5120}>5 GB (主管)</option>
|
||||
<option value={10240}>10 GB (高階)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddForm(false)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
創建
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* 郵件帳號列表 */}
|
||||
{emailAccounts.length === 0 ? (
|
||||
<div className="bg-gray-50 rounded-lg p-8 text-center text-gray-600">
|
||||
<p>此員工尚未建立任何郵件帳號</p>
|
||||
<p className="text-sm mt-2">點擊上方「新增郵件帳號」按鈕來創建</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{emailAccounts.map((account) => {
|
||||
const quotaInfo = account.used_mb
|
||||
? formatQuotaUsage(account.used_mb, account.quota_mb)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={account.id}
|
||||
className={`border rounded-lg p-4 ${
|
||||
account.is_active
|
||||
? 'bg-white border-gray-200'
|
||||
: 'bg-gray-50 border-gray-300 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
{/* 郵件地址 */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="text-lg font-semibold text-gray-900 font-mono">
|
||||
{account.email_address}
|
||||
</h4>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
account.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{account.is_active ? '啟用' : '停用'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 配額資訊 */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm text-gray-600">儲存空間</span>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{quotaInfo
|
||||
? `${quotaInfo.usedGB} / ${quotaInfo.totalGB} GB (${quotaInfo.percentage}%)`
|
||||
: `${(account.quota_mb / 1024).toFixed(2)} GB`}
|
||||
</span>
|
||||
</div>
|
||||
{quotaInfo && (
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${
|
||||
quotaInfo.percentage >= 90
|
||||
? 'bg-red-600'
|
||||
: quotaInfo.percentage >= 80
|
||||
? 'bg-yellow-600'
|
||||
: 'bg-green-600'
|
||||
}`}
|
||||
style={{ width: `${quotaInfo.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 轉寄與自動回覆 */}
|
||||
{account.forward_to && (
|
||||
<div className="text-sm text-gray-600 mb-1">
|
||||
<span className="font-medium">轉寄:</span> {account.forward_to}
|
||||
</div>
|
||||
)}
|
||||
{account.auto_reply && (
|
||||
<div className="text-sm text-gray-600 mb-1">
|
||||
<span className="font-medium">自動回覆:</span> 已啟用
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 時間戳 */}
|
||||
<div className="text-xs text-gray-500 mt-2">
|
||||
建立時間:{' '}
|
||||
{new Date(account.created_at).toLocaleString('zh-TW')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按鈕 */}
|
||||
<div className="flex flex-col gap-2 ml-4">
|
||||
<button
|
||||
onClick={() =>
|
||||
handleToggleActive(account.id, account.is_active)
|
||||
}
|
||||
className={`px-3 py-1 text-sm rounded ${
|
||||
account.is_active
|
||||
? 'bg-red-100 text-red-700 hover:bg-red-200'
|
||||
: 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||
}`}
|
||||
>
|
||||
{account.is_active ? '停用' : '啟用'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* WebMail 連結提示 */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 className="text-sm font-semibold text-blue-900 mb-2">
|
||||
📧 WebMail 登入說明
|
||||
</h4>
|
||||
<p className="text-sm text-blue-800">
|
||||
員工可使用 Keycloak SSO 登入{' '}
|
||||
<a
|
||||
href="https://mail.ease.taipei"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline font-semibold"
|
||||
>
|
||||
WebMail
|
||||
</a>
|
||||
,系統會自動載入所有啟用的郵件帳號並支援多帳號切換。
|
||||
</p>
|
||||
<p className="text-xs text-blue-700 mt-1">
|
||||
⚠️ 注意:員工無法自行新增郵件帳號,所有帳號必須由 HR 透過此系統授予。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
401
frontend/components/employees/permissions-tab.tsx
Normal file
401
frontend/components/employees/permissions-tab.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
|
||||
type SystemName = 'gitea' | 'portainer' | 'traefik' | 'keycloak'
|
||||
type AccessLevel = 'admin' | 'user' | 'readonly'
|
||||
|
||||
interface Permission {
|
||||
id: number
|
||||
employee_id: number
|
||||
system_name: SystemName
|
||||
access_level: AccessLevel
|
||||
granted_at: string
|
||||
granted_by?: number
|
||||
granted_by_name?: string
|
||||
}
|
||||
|
||||
interface PermissionsTabProps {
|
||||
employeeId: number
|
||||
}
|
||||
|
||||
interface SystemInfo {
|
||||
name: SystemName
|
||||
display_name: string
|
||||
description: string
|
||||
url: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const SYSTEM_INFO: Record<SystemName, SystemInfo> = {
|
||||
gitea: {
|
||||
name: 'gitea',
|
||||
display_name: 'Gitea',
|
||||
description: 'Git 代碼託管平台',
|
||||
url: 'https://git.lab.taipei',
|
||||
icon: '📦',
|
||||
},
|
||||
portainer: {
|
||||
name: 'portainer',
|
||||
display_name: 'Portainer',
|
||||
description: 'Docker 容器管理平台',
|
||||
url: 'https://portainer.lab.taipei',
|
||||
icon: '🐳',
|
||||
},
|
||||
traefik: {
|
||||
name: 'traefik',
|
||||
display_name: 'Traefik',
|
||||
description: '反向代理與負載均衡',
|
||||
url: 'https://traefik.lab.taipei',
|
||||
icon: '🔀',
|
||||
},
|
||||
keycloak: {
|
||||
name: 'keycloak',
|
||||
display_name: 'Keycloak',
|
||||
description: 'SSO 認證服務',
|
||||
url: 'https://auth.ease.taipei',
|
||||
icon: '🔐',
|
||||
},
|
||||
}
|
||||
|
||||
const ACCESS_LEVEL_LABELS: Record<AccessLevel, string> = {
|
||||
admin: '管理員',
|
||||
user: '使用者',
|
||||
readonly: '唯讀',
|
||||
}
|
||||
|
||||
const ACCESS_LEVEL_COLORS: Record<AccessLevel, string> = {
|
||||
admin: 'bg-red-100 text-red-800',
|
||||
user: 'bg-blue-100 text-blue-800',
|
||||
readonly: 'bg-gray-100 text-gray-800',
|
||||
}
|
||||
|
||||
export default function PermissionsTab({ employeeId }: PermissionsTabProps) {
|
||||
const { data: session } = useSession()
|
||||
const [permissions, setPermissions] = useState<Permission[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newPermission, setNewPermission] = useState({
|
||||
system_name: 'gitea' as SystemName,
|
||||
access_level: 'user' as AccessLevel,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchPermissions()
|
||||
}, [employeeId])
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL}/permissions/?employee_id=${employeeId}`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('無法載入權限資料')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setPermissions(data.items || [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '載入失敗')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGrantPermission = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// 檢查是否已有相同系統的權限
|
||||
const existingPermission = permissions.find(
|
||||
(p) => p.system_name === newPermission.system_name
|
||||
)
|
||||
|
||||
if (existingPermission) {
|
||||
if (
|
||||
!confirm(
|
||||
`此員工已有 ${SYSTEM_INFO[newPermission.system_name].display_name} 的權限 (${ACCESS_LEVEL_LABELS[existingPermission.access_level]}),是否要更新為 ${ACCESS_LEVEL_LABELS[newPermission.access_level]}?`
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL}/permissions/`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
employee_id: employeeId,
|
||||
system_name: newPermission.system_name,
|
||||
access_level: newPermission.access_level,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.detail || '授予權限失敗')
|
||||
}
|
||||
|
||||
alert('權限授予成功!')
|
||||
setShowAddForm(false)
|
||||
setNewPermission({ system_name: 'gitea', access_level: 'user' })
|
||||
fetchPermissions()
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : '操作失敗')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevokePermission = async (permissionId: number, systemName: SystemName) => {
|
||||
if (
|
||||
!confirm(
|
||||
`確定要撤銷此員工的 ${SYSTEM_INFO[systemName].display_name} 權限嗎?`
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL}/permissions/${permissionId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('撤銷權限失敗')
|
||||
}
|
||||
|
||||
alert('權限已撤銷')
|
||||
fetchPermissions()
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : '操作失敗')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-gray-600">載入中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 計算已授予和未授予的系統
|
||||
const grantedSystems = new Set(permissions.map((p) => p.system_name))
|
||||
const availableSystems = Object.keys(SYSTEM_INFO).filter(
|
||||
(sys) => !grantedSystems.has(sys as SystemName)
|
||||
) as SystemName[]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 標題與新增按鈕 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">系統權限列表</h3>
|
||||
{availableSystems.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{showAddForm ? '取消' : '+ 授予權限'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 授予權限表單 */}
|
||||
{showAddForm && (
|
||||
<form
|
||||
onSubmit={handleGrantPermission}
|
||||
className="bg-blue-50 border border-blue-200 rounded-lg p-6"
|
||||
>
|
||||
<h4 className="text-md font-semibold text-gray-900 mb-4">授予系統權限</h4>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
系統 *
|
||||
</label>
|
||||
<select
|
||||
value={newPermission.system_name}
|
||||
onChange={(e) =>
|
||||
setNewPermission({
|
||||
...newPermission,
|
||||
system_name: e.target.value as SystemName,
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{availableSystems.map((sys) => (
|
||||
<option key={sys} value={sys}>
|
||||
{SYSTEM_INFO[sys].icon} {SYSTEM_INFO[sys].display_name} -{' '}
|
||||
{SYSTEM_INFO[sys].description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
權限層級 *
|
||||
</label>
|
||||
<select
|
||||
value={newPermission.access_level}
|
||||
onChange={(e) =>
|
||||
setNewPermission({
|
||||
...newPermission,
|
||||
access_level: e.target.value as AccessLevel,
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="readonly">唯讀 (只能查看)</option>
|
||||
<option value="user">使用者 (可操作)</option>
|
||||
<option value="admin">管理員 (完整權限)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>權限說明:</strong>
|
||||
</p>
|
||||
<ul className="text-xs text-yellow-700 mt-1 list-disc list-inside">
|
||||
<li>唯讀: 只能查看資料,無法修改</li>
|
||||
<li>使用者: 可建立、編輯自己的資源</li>
|
||||
<li>管理員: 完整控制權限,包括管理其他用戶</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddForm(false)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
授予
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* 權限列表 */}
|
||||
{permissions.length === 0 ? (
|
||||
<div className="bg-gray-50 rounded-lg p-8 text-center text-gray-600">
|
||||
<p>此員工尚未被授予任何系統權限</p>
|
||||
<p className="text-sm mt-2">點擊上方「授予權限」按鈕來添加</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{permissions.map((permission) => {
|
||||
const systemInfo = SYSTEM_INFO[permission.system_name]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={permission.id}
|
||||
className="border border-gray-200 rounded-lg p-4 bg-white hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">{systemInfo.icon}</span>
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-gray-900">
|
||||
{systemInfo.display_name}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
{systemInfo.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<span
|
||||
className={`px-3 py-1 text-sm font-semibold rounded-full ${
|
||||
ACCESS_LEVEL_COLORS[permission.access_level]
|
||||
}`}
|
||||
>
|
||||
{ACCESS_LEVEL_LABELS[permission.access_level]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-3 space-y-2">
|
||||
<div className="text-xs text-gray-600">
|
||||
<span className="font-medium">授予時間:</span>{' '}
|
||||
{new Date(permission.granted_at).toLocaleString('zh-TW')}
|
||||
</div>
|
||||
{permission.granted_by_name && (
|
||||
<div className="text-xs text-gray-600">
|
||||
<span className="font-medium">授予人:</span>{' '}
|
||||
{permission.granted_by_name}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 mt-3">
|
||||
<a
|
||||
href={systemInfo.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 text-center px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
|
||||
>
|
||||
開啟系統
|
||||
</a>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleRevokePermission(
|
||||
permission.id,
|
||||
permission.system_name
|
||||
)
|
||||
}
|
||||
className="px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
>
|
||||
撤銷
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 系統說明 */}
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-2">
|
||||
🔒 權限管理說明
|
||||
</h4>
|
||||
<ul className="text-sm text-gray-700 space-y-1">
|
||||
<li>
|
||||
• 所有系統權限與 Keycloak Groups 同步,員工登入後自動生效
|
||||
</li>
|
||||
<li>• 權限撤銷後,員工將立即失去對該系統的存取權</li>
|
||||
<li>• 建議遵循最小權限原則,僅授予必要的權限</li>
|
||||
<li>• 所有權限變更都會記錄在審計日誌中</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
frontend/components/layout/breadcrumb.tsx
Normal file
97
frontend/components/layout/breadcrumb.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 麵包屑導航組件
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
interface BreadcrumbItem {
|
||||
name: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
export function Breadcrumb() {
|
||||
const pathname = usePathname()
|
||||
|
||||
// 定義路徑對應的顯示名稱
|
||||
const pathMap: Record<string, string> = {
|
||||
dashboard: '系統首頁',
|
||||
employees: '員工管理',
|
||||
new: '新增員工',
|
||||
edit: '編輯員工',
|
||||
departments: '部門管理',
|
||||
organization: '組織架構',
|
||||
'system-functions': '系統功能管理',
|
||||
'company-info': '公司資料維護',
|
||||
}
|
||||
|
||||
// 解析路徑生成麵包屑
|
||||
const generateBreadcrumbs = (): BreadcrumbItem[] => {
|
||||
const paths = pathname.split('/').filter((path) => path)
|
||||
const breadcrumbs: BreadcrumbItem[] = []
|
||||
|
||||
paths.forEach((path, index) => {
|
||||
// 跳過數字 ID (例如 /employees/123)
|
||||
if (/^\d+$/.test(path)) {
|
||||
breadcrumbs.push({
|
||||
name: `#${path}`,
|
||||
href: paths.slice(0, index + 1).join('/'),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const name = pathMap[path] || path
|
||||
const href = '/' + paths.slice(0, index + 1).join('/')
|
||||
|
||||
breadcrumbs.push({ name, href })
|
||||
})
|
||||
|
||||
return breadcrumbs
|
||||
}
|
||||
|
||||
const breadcrumbs = generateBreadcrumbs()
|
||||
|
||||
// 如果只有一層(例如只在 /dashboard),不顯示麵包屑
|
||||
if (breadcrumbs.length <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="flex mb-6" aria-label="Breadcrumb">
|
||||
<ol className="inline-flex items-center space-x-1 md:space-x-3">
|
||||
{breadcrumbs.map((crumb, index) => {
|
||||
const isLast = index === breadcrumbs.length - 1
|
||||
|
||||
return (
|
||||
<li key={crumb.href || crumb.name} className="inline-flex items-center">
|
||||
{index > 0 && (
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-400 mx-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{isLast ? (
|
||||
<span className="text-sm font-medium text-gray-500">{crumb.name}</span>
|
||||
) : (
|
||||
<Link
|
||||
href={crumb.href!}
|
||||
className="text-sm font-medium text-gray-700 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
{crumb.name}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
237
frontend/components/layout/sidebar.tsx
Normal file
237
frontend/components/layout/sidebar.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 側邊欄導航 (動態功能列表)
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { signOut, useSession } from 'next-auth/react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import apiClient from '@/lib/api-client'
|
||||
import { systemFunctionService, SystemFunctionNode } from '@/services/systemFunction.service'
|
||||
|
||||
// 預設圖示
|
||||
const DefaultIcon = () => (
|
||||
<svg className="w-5 h-5" 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>
|
||||
)
|
||||
|
||||
// 節點分類圖示
|
||||
const NodeIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
interface MenuItemProps {
|
||||
item: SystemFunctionNode
|
||||
level: number
|
||||
pathname: string
|
||||
}
|
||||
|
||||
function MenuItem({ item, level, pathname }: MenuItemProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const hasChildren = item.children && item.children.length > 0
|
||||
const isNode = item.function_type === 1
|
||||
const route = systemFunctionService.codeToRoute(item.code)
|
||||
const isActive = pathname === route || pathname.startsWith(route + '/')
|
||||
|
||||
if (isNode) {
|
||||
// NODE: 顯示為分類標題
|
||||
return (
|
||||
<div className="mb-1">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center px-3 py-1.5 text-sm font-semibold text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
{item.function_icon ? (
|
||||
<span className="text-lg">{item.function_icon}</span>
|
||||
) : (
|
||||
<NodeIcon />
|
||||
)}
|
||||
<span className="ml-3 flex-1 text-left">{item.name}</span>
|
||||
{hasChildren && (
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 子選單 */}
|
||||
{hasChildren && isExpanded && (
|
||||
<div className="ml-4 mt-1 space-y-1 border-l-2 border-gray-700 pl-2">
|
||||
{item.children.map(child => (
|
||||
<MenuItem key={child.id} item={child} level={level + 1} pathname={pathname} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// FUNCTION: 顯示為可點擊連結
|
||||
return (
|
||||
<Link
|
||||
href={route}
|
||||
className={`flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
level > 0 ? 'ml-2' : ''
|
||||
} ${
|
||||
isActive
|
||||
? 'bg-gray-800 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{item.function_icon ? (
|
||||
<span className="text-lg">{item.function_icon}</span>
|
||||
) : (
|
||||
<DefaultIcon />
|
||||
)}
|
||||
<span className="ml-3">{item.name}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname()
|
||||
const { data: session } = useSession()
|
||||
const [tenantName, setTenantName] = useState<string>('')
|
||||
const [menuTree, setMenuTree] = useState<SystemFunctionNode[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// 取得租戶資訊
|
||||
useEffect(() => {
|
||||
const fetchTenantInfo = async () => {
|
||||
try {
|
||||
const data: any = await apiClient.get('/tenants/current')
|
||||
setTenantName(data.name || data.code)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tenant info:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (session?.user) {
|
||||
fetchTenantInfo()
|
||||
}
|
||||
}, [session])
|
||||
|
||||
// 載入功能列表
|
||||
useEffect(() => {
|
||||
const loadMenu = async () => {
|
||||
try {
|
||||
// 直接從後端 API 取得租戶資訊
|
||||
let isSysmana = false
|
||||
|
||||
try {
|
||||
const tenantResponse: any = await apiClient.get('/tenants/current')
|
||||
isSysmana = tenantResponse.is_sysmana || false
|
||||
console.log('[Sidebar] Tenant is_sysmana:', isSysmana)
|
||||
} catch (error) {
|
||||
console.error('[Sidebar] Failed to fetch tenant info:', error)
|
||||
// 如果取得失敗,嘗試從 session 取得
|
||||
isSysmana = (session?.user as any)?.tenant?.is_sysmana || false
|
||||
}
|
||||
|
||||
console.log('[Sidebar] Loading menu with is_sysmana:', isSysmana)
|
||||
const tree = await systemFunctionService.getMenuTree(isSysmana)
|
||||
console.log('[Sidebar] Loaded menu tree:', tree.length, 'items')
|
||||
setMenuTree(tree)
|
||||
} catch (error) {
|
||||
console.error('[Sidebar] Failed to load menu:', error)
|
||||
// 失敗時使用空陣列
|
||||
setMenuTree([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (session?.user) {
|
||||
loadMenu()
|
||||
}
|
||||
}, [session])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gray-900 text-white">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center h-16 px-6 bg-gray-800">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">HR Portal</h1>
|
||||
{tenantName && (
|
||||
<p className="text-xs text-gray-400 mt-0.5">({tenantName})</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8 text-gray-400">
|
||||
<svg className="animate-spin h-8 w-8 mr-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<span>載入選單中...</span>
|
||||
</div>
|
||||
) : menuTree.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<p>無可用功能</p>
|
||||
</div>
|
||||
) : (
|
||||
menuTree.map(item => (
|
||||
<MenuItem key={item.id} item={item} level={0} pathname={pathname} />
|
||||
))
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* User Info & Logout */}
|
||||
<div className="p-4 bg-gray-800 border-t border-gray-700">
|
||||
{session?.user && (
|
||||
<div className="mb-3 px-2">
|
||||
<p className="text-sm font-medium">{session.user.name}</p>
|
||||
<p className="text-xs text-gray-400">{session.user.email}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: '/auth/signin' })}
|
||||
className="w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-700 rounded-lg 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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
登出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
96
frontend/components/ui/AlertDialog.tsx
Normal file
96
frontend/components/ui/AlertDialog.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 提示對話框組件 (單按鈕)
|
||||
*/
|
||||
'use client'
|
||||
|
||||
interface AlertDialogProps {
|
||||
isOpen: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
onConfirm: () => void
|
||||
type?: 'info' | 'warning' | 'error' | 'success'
|
||||
}
|
||||
|
||||
export default function AlertDialog({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmText = '確定',
|
||||
onConfirm,
|
||||
type = 'info',
|
||||
}: AlertDialogProps) {
|
||||
if (!isOpen) return null
|
||||
|
||||
const getIcon = () => {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return (
|
||||
<div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mb-4">
|
||||
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
case 'warning':
|
||||
return (
|
||||
<div className="w-12 h-12 rounded-full bg-yellow-100 flex items-center justify-center mb-4">
|
||||
<svg className="w-6 h-6 text-yellow-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>
|
||||
)
|
||||
case 'success':
|
||||
return (
|
||||
<div className="w-12 h-12 rounded-full bg-green-100 flex items-center justify-center mb-4">
|
||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
case 'info':
|
||||
return (
|
||||
<div className="w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center mb-4">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[100] p-4 animate-fadeIn"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-xl max-w-md w-full p-6 animate-slideIn"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="text-center">
|
||||
{getIcon()}
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
|
||||
<p className="text-sm text-gray-600 mb-6">{message}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`px-6 py-2 rounded-lg transition-colors text-sm font-medium text-white ${
|
||||
type === 'error'
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: type === 'success'
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: type === 'warning'
|
||||
? 'bg-yellow-600 hover:bg-yellow-700'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
102
frontend/components/ui/ConfirmDialog.tsx
Normal file
102
frontend/components/ui/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 確認對話框組件
|
||||
*/
|
||||
'use client'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
type?: 'info' | 'warning' | 'error' | 'success'
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmText = '確定',
|
||||
cancelText = '取消',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
type = 'warning',
|
||||
}: ConfirmDialogProps) {
|
||||
if (!isOpen) return null
|
||||
|
||||
const getIcon = () => {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return (
|
||||
<div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mb-4">
|
||||
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
case 'warning':
|
||||
return (
|
||||
<div className="w-12 h-12 rounded-full bg-yellow-100 flex items-center justify-center mb-4">
|
||||
<svg className="w-6 h-6 text-yellow-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>
|
||||
)
|
||||
case 'success':
|
||||
return (
|
||||
<div className="w-12 h-12 rounded-full bg-green-100 flex items-center justify-center mb-4">
|
||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
case 'info':
|
||||
return (
|
||||
<div className="w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center mb-4">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[100] p-4 animate-fadeIn"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-xl max-w-md w-full p-6 animate-slideIn"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="text-center">
|
||||
{getIcon()}
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
|
||||
<p className="text-sm text-gray-600 mb-6">{message}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`px-4 py-2 rounded-lg transition-colors text-sm font-medium text-white ${
|
||||
type === 'error' || type === 'warning'
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user