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>
283 lines
9.2 KiB
TypeScript
283 lines
9.2 KiB
TypeScript
'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>
|
|
)
|
|
}
|