feat: HR Portal - Complete Multi-Tenant System with Redis Session Storage

Major Features:
-  Multi-tenant architecture (tenant isolation)
-  Employee CRUD with lifecycle management (onboarding/offboarding)
-  Department tree structure with email domain management
-  Company info management (single-record editing)
-  System functions CRUD (permission management)
-  Email account management (multi-account per employee)
-  Keycloak SSO integration (auth.lab.taipei)
-  Redis session storage (10.1.0.254:6379)
  - Solves Cookie 4KB limitation
  - Cross-system session sharing
  - Sliding expiration (8 hours)
  - Automatic token refresh

Technical Stack:
Backend:
- FastAPI + SQLAlchemy
- PostgreSQL 16 (10.1.0.20:5433)
- Keycloak Admin API integration
- Docker Mailserver integration (SSH)
- Alembic migrations

Frontend:
- Next.js 14 (App Router)
- NextAuth 4 with Keycloak Provider
- Redis session storage (ioredis)
- Tailwind CSS

Infrastructure:
- Redis 7 (10.1.0.254:6379) - Session + Cache
- Keycloak 26.1.0 (auth.lab.taipei)
- Docker Mailserver (10.1.0.254)

Architecture Highlights:
- Session管理由 Keycloak + Redis 統一控制
- 支援多系統 (HR/WebMail/Calendar/Drive/Office) 共享 session
- Token 自動刷新,異質服務整合
- 未來可無縫遷移到雲端

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 20:12:43 +08:00
commit 360533393f
386 changed files with 70353 additions and 0 deletions

View File

@@ -0,0 +1,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>
)
}