Files
zerp/frontend/src/app/hr/page.tsx

1254 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect, useCallback } from 'react'
import ProtectedRoute from '@/components/ProtectedRoute'
import Modal from '@/components/Modal'
import LoadingSpinner from '@/components/LoadingSpinner'
import Link from 'next/link'
import { toast } from 'react-hot-toast'
import {
Users,
Plus,
Search,
UserPlus,
Briefcase,
Calendar,
DollarSign,
ArrowLeft,
Edit,
Trash2,
Loader2,
Mail,
Phone,
Building2,
User,
CheckCircle2,
XCircle,
Network
} from 'lucide-react'
import dynamic from 'next/dynamic'
import { employeesAPI, Employee, CreateEmployeeData, UpdateEmployeeData, EmployeeFilters, departmentsAPI, positionsAPI, type Department } from '@/lib/api/employees'
const OrgChart = dynamic(() => import('@/components/hr/OrgChart'), { ssr: false })
// Form fields extracted to module level to prevent focus loss on re-render
function EmployeeFormFields({
formData,
setFormData,
formErrors,
departments,
positions,
loadingDepts,
isEdit,
onCancel,
submitting,
}: {
formData: CreateEmployeeData
setFormData: React.Dispatch<React.SetStateAction<CreateEmployeeData>>
formErrors: Record<string, string>
departments: any[]
positions: any[]
loadingDepts: boolean
isEdit: boolean
onCancel: () => void
submitting: boolean
}) {
return (
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
First Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.firstName}
onChange={(e) => setFormData((prev) => ({ ...prev, firstName: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
/>
{formErrors.firstName && <p className="text-red-500 text-xs mt-1">{formErrors.firstName}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Last Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.lastName}
onChange={(e) => setFormData((prev) => ({ ...prev, lastName: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
/>
{formErrors.lastName && <p className="text-red-500 text-xs mt-1">{formErrors.lastName}</p>}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email <span className="text-red-500">*</span>
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData((prev) => ({ ...prev, email: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
/>
{formErrors.email && <p className="text-red-500 text-xs mt-1">{formErrors.email}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Mobile <span className="text-red-500">*</span>
</label>
<input
type="tel"
value={formData.mobile}
onChange={(e) => setFormData((prev) => ({ ...prev, mobile: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
/>
{formErrors.mobile && <p className="text-red-500 text-xs mt-1">{formErrors.mobile}</p>}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Department <span className="text-red-500">*</span>
</label>
<select
value={formData.departmentId}
onChange={(e) => setFormData((prev) => ({ ...prev, departmentId: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
disabled={loadingDepts}
>
<option value="">Select Department</option>
{departments.map((dept) => (
<option key={dept.id} value={dept.id}>{dept.nameAr || dept.name}</option>
))}
</select>
{formErrors.departmentId && <p className="text-red-500 text-xs mt-1">{formErrors.departmentId}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Position <span className="text-red-500">*</span>
</label>
<select
value={formData.positionId}
onChange={(e) => setFormData((prev) => ({ ...prev, positionId: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
disabled={loadingDepts}
>
<option value="">Select Position</option>
{positions.map((pos) => (
<option key={pos.id} value={pos.id}>{pos.titleAr || pos.title}</option>
))}
</select>
{formErrors.positionId && <p className="text-red-500 text-xs mt-1">{formErrors.positionId}</p>}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Employment Type <span className="text-red-500">*</span>
</label>
<select
value={formData.employmentType}
onChange={(e) => setFormData((prev) => ({ ...prev, employmentType: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
>
<option value="FULL_TIME">Full Time - دوام كامل</option>
<option value="PART_TIME">Part Time - دوام جزئي</option>
<option value="CONTRACT">Contract - عقد</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Contract Type
</label>
<select
value={formData.contractType || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, contractType: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
>
<option value="UNLIMITED">Unlimited - غير محدود</option>
<option value="FIXED">Fixed - محدد</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Hire Date <span className="text-red-500">*</span>
</label>
<input
type="date"
value={formData.hireDate}
onChange={(e) => setFormData((prev) => ({ ...prev, hireDate: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
/>
{formErrors.hireDate && <p className="text-red-500 text-xs mt-1">{formErrors.hireDate}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Base Salary (SAR) <span className="text-red-500">*</span>
</label>
<input
type="number"
min="0"
value={formData.baseSalary}
onChange={(e) => setFormData((prev) => ({ ...prev, baseSalary: parseFloat(e.target.value) || 0 }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
/>
{formErrors.baseSalary && <p className="text-red-500 text-xs mt-1">{formErrors.baseSalary}</p>}
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
disabled={submitting}
>
Cancel
</button>
<button
type="submit"
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={submitting}
>
{submitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{isEdit ? 'Updating...' : 'Creating...'}
</>
) : (
<>
{isEdit ? 'Update Employee' : 'Create Employee'}
</>
)}
</button>
</div>
</div>
)
}
function HRContent() {
// State Management
const [employees, setEmployees] = useState<Employee[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Pagination
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [total, setTotal] = useState(0)
const pageSize = 10
// Filters
const [searchTerm, setSearchTerm] = useState('')
const [selectedDepartment, setSelectedDepartment] = useState('all')
const [selectedStatus, setSelectedStatus] = useState('all')
// Modals
const [showCreateModal, setShowCreateModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [selectedEmployee, setSelectedEmployee] = useState<Employee | null>(null)
// Form Data
const [formData, setFormData] = useState<CreateEmployeeData>({
firstName: '',
lastName: '',
firstNameAr: '',
lastNameAr: '',
email: '',
phone: '',
mobile: '',
dateOfBirth: '',
gender: '',
nationality: 'Saudi Arabia',
nationalId: '',
employmentType: 'FULL_TIME',
contractType: 'UNLIMITED',
hireDate: '',
departmentId: '',
positionId: '',
reportingToId: '',
baseSalary: 0
})
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// Departments & Positions for dropdowns
const [departments, setDepartments] = useState<any[]>([])
const [positions, setPositions] = useState<any[]>([])
const [loadingDepts, setLoadingDepts] = useState(false)
// Tabs: employees | departments | orgchart
const [activeTab, setActiveTab] = useState<'employees' | 'departments' | 'orgchart'>('employees')
const [hierarchy, setHierarchy] = useState<Department[]>([])
const [loadingHierarchy, setLoadingHierarchy] = useState(false)
// Department CRUD
const [showDeptModal, setShowDeptModal] = useState(false)
const [editingDept, setEditingDept] = useState<Department | null>(null)
const [deptFormData, setDeptFormData] = useState({ name: '', nameAr: '', code: '', parentId: '' as string, description: '' })
const [deptFormErrors, setDeptFormErrors] = useState<Record<string, string>>({})
const [deptDeleteConfirm, setDeptDeleteConfirm] = useState<string | null>(null)
const fetchDepartments = useCallback(async () => {
setLoadingDepts(true)
try {
const depts = await departmentsAPI.getAll()
setDepartments(depts)
} catch (err) {
console.error('Failed to load departments:', err)
} finally {
setLoadingDepts(false)
}
}, [])
const fetchHierarchy = useCallback(async () => {
setLoadingHierarchy(true)
try {
const tree = await departmentsAPI.getHierarchy()
setHierarchy(tree)
} catch (err) {
console.error('Failed to load hierarchy:', err)
} finally {
setLoadingHierarchy(false)
}
}, [])
// Fetch Departments & Positions
useEffect(() => {
const fetchData = async () => {
setLoadingDepts(true)
try {
const [depts, poss] = await Promise.all([
departmentsAPI.getAll(),
positionsAPI.getAll()
])
setDepartments(depts)
setPositions(poss)
} catch (err) {
console.error('Failed to load departments/positions:', err)
} finally {
setLoadingDepts(false)
}
}
fetchData()
}, [])
useEffect(() => {
if (activeTab === 'departments') fetchDepartments()
if (activeTab === 'orgchart') fetchHierarchy()
}, [activeTab, fetchDepartments, fetchHierarchy])
// Fetch Employees (with debouncing for search)
const fetchEmployees = useCallback(async () => {
setLoading(true)
setError(null)
try {
const filters: EmployeeFilters = {
page: currentPage,
pageSize,
}
if (searchTerm) filters.search = searchTerm
if (selectedDepartment !== 'all') filters.departmentId = selectedDepartment
if (selectedStatus !== 'all') filters.status = selectedStatus
const data = await employeesAPI.getAll(filters)
setEmployees(data.employees)
setTotal(data.total)
setTotalPages(data.totalPages)
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to load employees')
toast.error('Failed to load employees')
} finally {
setLoading(false)
}
}, [currentPage, searchTerm, selectedDepartment, selectedStatus])
// Debounced search
useEffect(() => {
const debounce = setTimeout(() => {
setCurrentPage(1)
fetchEmployees()
}, 500)
return () => clearTimeout(debounce)
}, [searchTerm])
// Fetch on filter/page change
useEffect(() => {
fetchEmployees()
}, [currentPage, selectedDepartment, selectedStatus])
// Form Validation
const validateForm = (): boolean => {
const errors: Record<string, string> = {}
if (!formData.firstName || formData.firstName.trim().length < 2) {
errors.firstName = 'First name must be at least 2 characters'
}
if (!formData.lastName || formData.lastName.trim().length < 2) {
errors.lastName = 'Last name must be at least 2 characters'
}
if (!formData.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
errors.email = 'Invalid email format'
}
if (!formData.mobile) {
errors.mobile = 'Mobile is required'
}
if (!formData.departmentId) {
errors.departmentId = 'Department is required'
}
if (!formData.positionId) {
errors.positionId = 'Position is required'
}
if (!formData.hireDate) {
errors.hireDate = 'Hire date is required'
}
if (!formData.baseSalary || formData.baseSalary <= 0) {
errors.baseSalary = 'Base salary must be greater than 0'
}
setFormErrors(errors)
return Object.keys(errors).length === 0
}
// Create Employee
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
if (!validateForm()) {
toast.error('Please fix form errors')
return
}
setSubmitting(true)
try {
await employeesAPI.create(formData)
toast.success('Employee created successfully!')
setShowCreateModal(false)
resetForm()
fetchEmployees()
} catch (err: any) {
const message = err.response?.data?.message || 'Failed to create employee'
toast.error(message)
} finally {
setSubmitting(false)
}
}
// Edit Employee
const handleEdit = async (e: React.FormEvent) => {
e.preventDefault()
if (!selectedEmployee || !validateForm()) {
toast.error('Please fix form errors')
return
}
setSubmitting(true)
try {
await employeesAPI.update(selectedEmployee.id, formData as UpdateEmployeeData)
toast.success('Employee updated successfully!')
setShowEditModal(false)
resetForm()
fetchEmployees()
} catch (err: any) {
const message = err.response?.data?.message || 'Failed to update employee'
toast.error(message)
} finally {
setSubmitting(false)
}
}
// Delete Employee
const handleDelete = async () => {
if (!selectedEmployee) return
setSubmitting(true)
try {
await employeesAPI.delete(selectedEmployee.id)
toast.success('Employee deleted successfully!')
setShowDeleteDialog(false)
setSelectedEmployee(null)
fetchEmployees()
} catch (err: any) {
const message = err.response?.data?.message || 'Failed to delete employee'
toast.error(message)
} finally {
setSubmitting(false)
}
}
// Utility Functions
const resetForm = () => {
setFormData({
firstName: '',
lastName: '',
firstNameAr: '',
lastNameAr: '',
email: '',
phone: '',
mobile: '',
dateOfBirth: '',
gender: '',
nationality: 'Saudi Arabia',
nationalId: '',
employmentType: 'FULL_TIME',
contractType: 'UNLIMITED',
hireDate: '',
departmentId: '',
positionId: '',
reportingToId: '',
baseSalary: 0
})
setFormErrors({})
setSelectedEmployee(null)
}
const openEditModal = (employee: Employee) => {
setSelectedEmployee(employee)
setFormData({
firstName: employee.firstName,
lastName: employee.lastName,
firstNameAr: employee.firstNameAr,
lastNameAr: employee.lastNameAr,
email: employee.email,
phone: employee.phone,
mobile: employee.mobile,
dateOfBirth: employee.dateOfBirth?.split('T')[0],
gender: employee.gender,
nationality: employee.nationality,
nationalId: employee.nationalId,
employmentType: employee.employmentType,
contractType: employee.contractType,
hireDate: employee.hireDate?.split('T')[0],
departmentId: employee.departmentId,
positionId: employee.positionId,
reportingToId: employee.reportingToId,
baseSalary: employee.baseSalary ?? (employee as any).basicSalary ?? 0
})
setShowEditModal(true)
}
const openDeleteDialog = (employee: Employee) => {
setSelectedEmployee(employee)
setShowDeleteDialog(true)
}
// Department CRUD
const openDeptModal = (dept?: Department) => {
if (dept) {
setEditingDept(dept)
setDeptFormData({
name: dept.name,
nameAr: dept.nameAr || '',
code: dept.code,
parentId: dept.parentId || '',
description: dept.description || ''
})
} else {
setEditingDept(null)
setDeptFormData({ name: '', nameAr: '', code: '', parentId: '', description: '' })
}
setDeptFormErrors({})
setShowDeptModal(true)
}
const validateDeptForm = () => {
const err: Record<string, string> = {}
if (!deptFormData.name?.trim()) err.name = 'Name is required'
if (!deptFormData.code?.trim()) err.code = 'Code is required'
setDeptFormErrors(err)
return Object.keys(err).length === 0
}
const handleSaveDept = async (e: React.FormEvent) => {
e.preventDefault()
if (!validateDeptForm()) return
try {
if (editingDept) {
await departmentsAPI.update(editingDept.id, {
...deptFormData,
parentId: deptFormData.parentId || null
})
toast.success('Department updated')
} else {
await departmentsAPI.create({
...deptFormData,
parentId: deptFormData.parentId || undefined
})
toast.success('Department created')
}
setShowDeptModal(false)
fetchDepartments()
if (activeTab === 'orgchart') fetchHierarchy()
setDepartments(await departmentsAPI.getAll())
} catch (err: any) {
toast.error(err.response?.data?.message || 'Failed to save department')
}
}
const handleDeleteDept = async (id: string) => {
try {
await departmentsAPI.delete(id)
toast.success('Department deleted')
setDeptDeleteConfirm(null)
fetchDepartments()
if (activeTab === 'orgchart') fetchHierarchy()
setDepartments(await departmentsAPI.getAll())
} catch (err: any) {
toast.error(err.response?.data?.message || 'Failed to delete department')
setDeptDeleteConfirm(null)
}
}
// Calculate stats (coerce to number - API may return Decimal as string/object)
const activeEmployees = employees.filter(e => e.status === 'ACTIVE').length
const totalSalary = employees.reduce((sum, e) => {
const sal = e.baseSalary ?? (e as any).basicSalary ?? 0
return sum + Number(sal)
}, 0)
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
href="/dashboard"
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<ArrowLeft className="h-5 w-5 text-gray-600" />
</Link>
<div className="flex items-center gap-3">
<div className="bg-red-100 p-2 rounded-lg">
<Users className="h-6 w-6 text-red-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">إدارة الموارد البشرية</h1>
<p className="text-sm text-gray-600">HR Management System</p>
</div>
</div>
</div>
<div className="flex items-center gap-3">
{activeTab === 'employees' && (
<button
onClick={() => {
resetForm()
setShowCreateModal(true)
}}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
<Plus className="h-4 w-4" />
Add Employee
</button>
)}
{activeTab === 'departments' && (
<button
onClick={() => openDeptModal()}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
<Plus className="h-4 w-4" />
Add Department
</button>
)}
</div>
</div>
</div>
{/* Tabs */}
<div className="border-b border-gray-200 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<nav className="flex gap-4">
<button
onClick={() => setActiveTab('employees')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'employees'
? 'border-red-600 text-red-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<span className="flex items-center gap-2">
<Users className="h-4 w-4" />
الموظفون / Employees
</span>
</button>
<button
onClick={() => setActiveTab('departments')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'departments'
? 'border-red-600 text-red-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<span className="flex items-center gap-2">
<Building2 className="h-4 w-4" />
الأقسام / Departments
</span>
</button>
<button
onClick={() => setActiveTab('orgchart')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'orgchart'
? 'border-red-600 text-red-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<span className="flex items-center gap-2">
<Network className="h-4 w-4" />
الهيكل التنظيمي / Org Chart
</span>
</button>
</nav>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Total Employees</p>
<p className="text-3xl font-bold text-gray-900 mt-1">{total}</p>
</div>
<div className="bg-blue-100 p-3 rounded-lg">
<Users className="h-8 w-8 text-blue-600" />
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Active</p>
<p className="text-3xl font-bold text-gray-900 mt-1">{activeEmployees}</p>
</div>
<div className="bg-green-100 p-3 rounded-lg">
<CheckCircle2 className="h-8 w-8 text-green-600" />
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Departments</p>
<p className="text-3xl font-bold text-gray-900 mt-1">{departments.length}</p>
</div>
<div className="bg-purple-100 p-3 rounded-lg">
<Building2 className="h-8 w-8 text-purple-600" />
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Total Salary</p>
<p className="text-3xl font-bold text-gray-900 mt-1">
{totalSalary >= 1000
? `${(totalSalary / 1000).toFixed(1)}K`
: totalSalary.toLocaleString()}
</p>
<p className="text-xs text-gray-600 mt-1">SAR</p>
</div>
<div className="bg-orange-100 p-3 rounded-lg">
<DollarSign className="h-8 w-8 text-orange-600" />
</div>
</div>
</div>
</div>
{/* Employees Tab */}
{activeTab === 'employees' && (
<>
{/* Filters and Search */}
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200 mb-6">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="Search employees..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pr-10 pl-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
/>
</div>
<select
value={selectedDepartment}
onChange={(e) => setSelectedDepartment(e.target.value)}
className="px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
>
<option value="all">All Departments</option>
{departments.map(dept => (
<option key={dept.id} value={dept.id}>{dept.nameAr || dept.name}</option>
))}
</select>
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value)}
className="px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
>
<option value="all">All Status</option>
<option value="ACTIVE">Active</option>
<option value="INACTIVE">Inactive</option>
</select>
</div>
</div>
{/* Employees Table */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{loading ? (
<div className="p-12">
<LoadingSpinner size="lg" message="Loading employees..." />
</div>
) : error ? (
<div className="p-12 text-center">
<p className="text-red-600 mb-4">{error}</p>
<button
onClick={fetchEmployees}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
Retry
</button>
</div>
) : employees.length === 0 ? (
<div className="p-12 text-center">
<Users className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600 mb-4">No employees found</p>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
Create First Employee
</button>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Employee</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Contact</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Department</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Position</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Salary</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Status</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{employees.map((employee) => (
<tr key={employee.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-red-500 to-red-600 flex items-center justify-center text-white font-bold">
{employee.firstName.charAt(0)}{employee.lastName.charAt(0)}
</div>
<div>
<p className="font-semibold text-gray-900">
{employee.firstName} {employee.lastName}
</p>
<p className="text-xs text-gray-600">{employee.uniqueEmployeeId}</p>
</div>
</div>
</td>
<td className="px-6 py-4">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-gray-600">
<Mail className="h-4 w-4" />
{employee.email}
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<Phone className="h-4 w-4" />
{employee.mobile}
</div>
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-900">
{employee.department?.nameAr || employee.department?.name || 'N/A'}
</span>
</div>
</td>
<td className="px-6 py-4">
<span className="text-sm text-gray-900">
{employee.position?.titleAr || employee.position?.title || 'N/A'}
</span>
</td>
<td className="px-6 py-4">
<span className="text-sm font-semibold text-gray-900">
{Number(employee.baseSalary ?? (employee as any).basicSalary ?? 0).toLocaleString()} SAR
</span>
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
employee.status === 'ACTIVE'
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-700'
}`}>
{employee.status === 'ACTIVE' ? <CheckCircle2 className="h-3 w-3 mr-1" /> : <XCircle className="h-3 w-3 mr-1" />}
{employee.status}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<button
onClick={() => openEditModal(employee)}
className="p-2 hover:bg-green-50 text-green-600 rounded-lg transition-colors"
title="Edit"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => openDeleteDialog(employee)}
className="p-2 hover:bg-red-50 text-red-600 rounded-lg transition-colors"
title="Delete"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<p className="text-sm text-gray-600">
Showing <span className="font-semibold">{((currentPage - 1) * pageSize) + 1}</span> to{' '}
<span className="font-semibold">{Math.min(currentPage * pageSize, total)}</span> of{' '}
<span className="font-semibold">{total}</span> employees
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const page = i + 1
return (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={`px-4 py-2 rounded-lg transition-colors ${
currentPage === page
? 'bg-red-600 text-white'
: 'border border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
{page}
</button>
)
})}
{totalPages > 5 && <span className="px-2">...</span>}
<button
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
</>
)}
</div>
</>
)}
{/* Departments Tab */}
{activeTab === 'departments' && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{loadingDepts ? (
<div className="p-12"><LoadingSpinner size="lg" message="Loading departments..." /></div>
) : departments.length === 0 ? (
<div className="p-12 text-center">
<Building2 className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600 mb-4">No departments yet. Add your first department.</p>
<button onClick={() => openDeptModal()} className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700">
Add Department
</button>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Name</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Code</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Parent</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Employees</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{departments.map((dept) => (
<tr key={dept.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<span className="font-semibold text-gray-900">{dept.nameAr || dept.name}</span>
</td>
<td className="px-6 py-4 text-sm text-gray-600">{dept.code}</td>
<td className="px-6 py-4 text-sm text-gray-600">{dept.parent?.nameAr || dept.parent?.name || '-'}</td>
<td className="px-6 py-4 text-sm">{dept._count?.employees ?? 0}</td>
<td className="px-6 py-4">
<div className="flex gap-2">
<button onClick={() => openDeptModal(dept)} className="p-2 hover:bg-blue-50 text-blue-600 rounded-lg" title="Edit">
<Edit className="h-4 w-4" />
</button>
{deptDeleteConfirm === dept.id ? (
<span className="flex gap-1">
<button onClick={() => handleDeleteDept(dept.id)} className="px-2 py-1 text-xs bg-red-600 text-white rounded">Confirm</button>
<button onClick={() => setDeptDeleteConfirm(null)} className="px-2 py-1 text-xs border rounded">Cancel</button>
</span>
) : (
<button onClick={() => setDeptDeleteConfirm(dept.id)} className="p-2 hover:bg-red-50 text-red-600 rounded-lg" title="Delete">
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Org Chart Tab */}
{activeTab === 'orgchart' && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{loadingHierarchy ? (
<div className="p-12"><LoadingSpinner size="lg" message="Loading org chart..." /></div>
) : (
<OrgChart hierarchy={hierarchy} />
)}
</div>
)}
</main>
{/* Create Modal */}
<Modal
isOpen={showCreateModal}
onClose={() => {
setShowCreateModal(false)
resetForm()
}}
title="Create New Employee"
size="xl"
>
<form onSubmit={handleCreate}>
<EmployeeFormFields
formData={formData}
setFormData={setFormData}
formErrors={formErrors}
departments={departments}
positions={positions}
loadingDepts={loadingDepts}
isEdit={false}
onCancel={() => {
setShowCreateModal(false)
resetForm()
}}
submitting={submitting}
/>
</form>
</Modal>
{/* Department Modal */}
<Modal
isOpen={showDeptModal}
onClose={() => { setShowDeptModal(false); setEditingDept(null) }}
title={editingDept ? 'Edit Department' : 'Add Department'}
>
<form onSubmit={handleSaveDept} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name (EN) *</label>
<input
type="text"
value={deptFormData.name}
onChange={(e) => setDeptFormData((p) => ({ ...p, name: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
/>
{deptFormErrors.name && <p className="text-red-500 text-xs mt-1">{deptFormErrors.name}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name (AR)</label>
<input
type="text"
value={deptFormData.nameAr}
onChange={(e) => setDeptFormData((p) => ({ ...p, nameAr: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
dir="rtl"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Code *</label>
<input
type="text"
value={deptFormData.code}
onChange={(e) => setDeptFormData((p) => ({ ...p, code: e.target.value.toUpperCase() }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
placeholder="e.g. HR, IT, SALES"
/>
{deptFormErrors.code && <p className="text-red-500 text-xs mt-1">{deptFormErrors.code}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Parent Department</label>
<select
value={deptFormData.parentId}
onChange={(e) => setDeptFormData((p) => ({ ...p, parentId: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
>
<option value=""> None (Root) </option>
{departments
.filter((d) => !editingDept || d.id !== editingDept.id)
.map((d) => (
<option key={d.id} value={d.id}>{d.nameAr || d.name} ({d.code})</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea
value={deptFormData.description}
onChange={(e) => setDeptFormData((p) => ({ ...p, description: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
rows={2}
/>
</div>
<div className="flex justify-end gap-2 pt-4">
<button type="button" onClick={() => setShowDeptModal(false)} className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
Cancel
</button>
<button type="submit" className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700">
{editingDept ? 'Update' : 'Create'}
</button>
</div>
</form>
</Modal>
{/* Edit Modal */}
<Modal
isOpen={showEditModal}
onClose={() => {
setShowEditModal(false)
resetForm()
}}
title="Edit Employee"
size="xl"
>
<form onSubmit={handleEdit}>
<EmployeeFormFields
formData={formData}
setFormData={setFormData}
formErrors={formErrors}
departments={departments}
positions={positions}
loadingDepts={loadingDepts}
isEdit
onCancel={() => {
setShowEditModal(false)
resetForm()
}}
submitting={submitting}
/>
</form>
</Modal>
{/* Delete Confirmation Dialog */}
{showDeleteDialog && selectedEmployee && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowDeleteDialog(false)} />
<div className="flex min-h-screen items-center justify-center p-4">
<div className="relative bg-white rounded-xl shadow-2xl p-6 max-w-md w-full">
<div className="flex items-center gap-4 mb-4">
<div className="bg-red-100 p-3 rounded-full">
<Trash2 className="h-6 w-6 text-red-600" />
</div>
<div>
<h3 className="text-lg font-bold text-gray-900">Delete Employee</h3>
<p className="text-sm text-gray-600">This action cannot be undone</p>
</div>
</div>
<p className="text-gray-700 mb-6">
Are you sure you want to delete <span className="font-semibold">{selectedEmployee.firstName} {selectedEmployee.lastName}</span>?
</p>
<div className="flex items-center justify-end gap-3">
<button
onClick={() => {
setShowDeleteDialog(false)
setSelectedEmployee(null)
}}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
disabled={submitting}
>
Cancel
</button>
<button
onClick={handleDelete}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
disabled={submitting}
>
{submitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Deleting...
</>
) : (
'Delete Employee'
)}
</button>
</div>
</div>
</div>
</div>
)}
</div>
)
}
export default function HRPage() {
return (
<ProtectedRoute>
<HRContent />
</ProtectedRoute>
)
}