Files
hr-portal/frontend/components/OnboardingForm.tsx
Porsche Chen 360533393f 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>
2026-02-23 20:12:43 +08:00

449 lines
15 KiB
TypeScript

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