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>
449 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|