RBAC: Phase 1-3, Total Salary fix, employee creation fix, permission groups, backup script
Made-with: Cursor
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user