RBAC: Phase 1-3, Total Salary fix, employee creation fix, permission groups, backup script

Made-with: Cursor
This commit is contained in:
Talal Sharabi
2026-03-04 19:31:08 +04:00
parent 6034f774ed
commit 8edeaf10f5
46 changed files with 2751 additions and 598 deletions

View File

@@ -23,9 +23,221 @@ import {
Building2,
User,
CheckCircle2,
XCircle
XCircle,
Network
} from 'lucide-react'
import { employeesAPI, Employee, CreateEmployeeData, UpdateEmployeeData, EmployeeFilters, departmentsAPI, positionsAPI } from '@/lib/api/employees'
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
@@ -79,6 +291,42 @@ function HRContent() {
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 () => {
@@ -99,6 +347,11 @@ function HRContent() {
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)
@@ -290,7 +543,7 @@ function HRContent() {
departmentId: employee.departmentId,
positionId: employee.positionId,
reportingToId: employee.reportingToId,
baseSalary: employee.baseSalary
baseSalary: employee.baseSalary ?? (employee as any).basicSalary ?? 0
})
setShowEditModal(true)
}
@@ -300,198 +553,79 @@ function HRContent() {
setShowDeleteDialog(true)
}
// Calculate stats
// 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) => sum + e.baseSalary, 0)
// Render Form Fields Component
const FormFields = ({ isEdit = false }: { isEdit?: boolean }) => (
<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({ ...formData, 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({ ...formData, 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({ ...formData, 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({ ...formData, 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({ ...formData, 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({ ...formData, 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({ ...formData, 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({ ...formData, 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({ ...formData, 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({ ...formData, baseSalary: parseFloat(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.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={() => {
isEdit ? setShowEditModal(false) : setShowCreateModal(false)
resetForm()
}}
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>
)
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">
@@ -518,19 +652,77 @@ function HRContent() {
</div>
<div className="flex items-center gap-3">
<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 === '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">
@@ -577,7 +769,9 @@ function HRContent() {
<div>
<p className="text-sm text-gray-600">Total Salary</p>
<p className="text-3xl font-bold text-gray-900 mt-1">
{(totalSalary / 1000).toFixed(0)}K
{totalSalary >= 1000
? `${(totalSalary / 1000).toFixed(1)}K`
: totalSalary.toLocaleString()}
</p>
<p className="text-xs text-gray-600 mt-1">SAR</p>
</div>
@@ -588,6 +782,9 @@ function HRContent() {
</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">
@@ -710,7 +907,7 @@ function HRContent() {
</td>
<td className="px-6 py-4">
<span className="text-sm font-semibold text-gray-900">
{employee.baseSalary.toLocaleString()} SAR
{Number(employee.baseSalary ?? (employee as any).basicSalary ?? 0).toLocaleString()} SAR
</span>
</td>
<td className="px-6 py-4">
@@ -791,6 +988,79 @@ function HRContent() {
</>
)}
</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 */}
@@ -804,7 +1074,93 @@ function HRContent() {
size="xl"
>
<form onSubmit={handleCreate}>
<FormFields />
<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>
@@ -819,7 +1175,20 @@ function HRContent() {
size="xl"
>
<form onSubmit={handleEdit}>
<FormFields isEdit />
<EmployeeFormFields
formData={formData}
setFormData={setFormData}
formErrors={formErrors}
departments={departments}
positions={positions}
loadingDepts={loadingDepts}
isEdit
onCancel={() => {
setShowEditModal(false)
resetForm()
}}
submitting={submitting}
/>
</form>
</Modal>