feat(hr): Complete HR module with Employee Portal, Loans, Leave, Purchase Requests, Contracts

- Database: Add Loan, LoanInstallment, PurchaseRequest, LeaveEntitlement, EmployeeContract models
- Database: Extend Attendance with ZK Tico fields (sourceDeviceId, externalId, rawData)
- Database: Add Employee.attendancePin for device mapping
- Backend: HR admin - Loans, Purchase Requests, Leave entitlements, Employee contracts CRUD
- Backend: Leave reject, bulk attendance sync (ZK Tico ready)
- Backend: Employee Portal API - scoped by employeeId (loans, leaves, purchase-requests, attendance, salaries)
- Frontend: Employee Portal - dashboard, loans, leave, purchase-requests, attendance, salaries
- Frontend: HR Admin - new tabs for Leaves, Loans, Purchase Requests, Contracts (approve/reject)
- Dashboard: Add My Portal link
- No destructive schema changes; additive migrations only

Made-with: Cursor
This commit is contained in:
Talal Sharabi
2026-03-04 19:44:09 +04:00
parent ae890ca1c5
commit 72ed9a2ff5
18 changed files with 2649 additions and 8 deletions

View File

@@ -0,0 +1,189 @@
'use client'
import { useState, useEffect } from 'react'
import { portalAPI } from '@/lib/api/portal'
import Modal from '@/components/Modal'
import LoadingSpinner from '@/components/LoadingSpinner'
import { toast } from 'react-hot-toast'
import { Calendar, Plus } from 'lucide-react'
const LEAVE_TYPES = [
{ value: 'ANNUAL', label: 'إجازة سنوية' },
{ value: 'SICK', label: 'إجازة مرضية' },
{ value: 'EMERGENCY', label: 'طوارئ' },
{ value: 'UNPAID', label: 'بدون راتب' },
]
const STATUS_MAP: Record<string, { label: string; color: string }> = {
PENDING: { label: 'قيد المراجعة', color: 'bg-amber-100 text-amber-800' },
APPROVED: { label: 'معتمدة', color: 'bg-green-100 text-green-800' },
REJECTED: { label: 'مرفوضة', color: 'bg-red-100 text-red-800' },
}
export default function PortalLeavePage() {
const [leaveBalance, setLeaveBalance] = useState<any[]>([])
const [leaves, setLeaves] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [form, setForm] = useState({ leaveType: 'ANNUAL', startDate: '', endDate: '', reason: '' })
const load = () => {
setLoading(true)
Promise.all([portalAPI.getLeaveBalance(), portalAPI.getLeaves()])
.then(([balance, list]) => {
setLeaveBalance(balance)
setLeaves(list)
})
.catch(() => toast.error('فشل تحميل البيانات'))
.finally(() => setLoading(false))
}
useEffect(() => load(), [])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!form.startDate || !form.endDate) {
toast.error('أدخل تاريخ البداية والنهاية')
return
}
if (new Date(form.endDate) < new Date(form.startDate)) {
toast.error('تاريخ النهاية يجب أن يكون بعد تاريخ البداية')
return
}
setSubmitting(true)
portalAPI.submitLeaveRequest({
leaveType: form.leaveType,
startDate: form.startDate,
endDate: form.endDate,
reason: form.reason || undefined,
})
.then(() => {
setShowModal(false)
setForm({ leaveType: 'ANNUAL', startDate: '', endDate: '', reason: '' })
toast.success('تم إرسال طلب الإجازة')
load()
})
.catch(() => toast.error('فشل إرسال الطلب'))
.finally(() => setSubmitting(false))
}
if (loading) return <LoadingSpinner />
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">إجازاتي</h1>
<button
onClick={() => setShowModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
>
<Plus className="h-4 w-4" />
طلب إجازة
</button>
</div>
{leaveBalance.length > 0 && (
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<h2 className="text-lg font-semibold text-gray-900 mb-4">رصيد الإجازات</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{leaveBalance.map((b) => (
<div key={b.leaveType} className="border rounded-lg p-4">
<p className="text-sm text-gray-600">
{b.leaveType === 'ANNUAL' ? 'سنوية' : b.leaveType === 'SICK' ? 'مرضية' : b.leaveType}
</p>
<p className="text-2xl font-bold text-teal-600 mt-1">{b.available} يوم</p>
<p className="text-xs text-gray-500">من {b.totalDays + b.carriedOver} (مستخدم: {b.usedDays})</p>
</div>
))}
</div>
</div>
)}
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<h2 className="text-lg font-semibold text-gray-900 mb-4">طلباتي</h2>
{leaves.length === 0 ? (
<p className="text-gray-500 text-center py-8">لا توجد طلبات إجازة</p>
) : (
<div className="space-y-3">
{leaves.map((l) => {
const statusInfo = STATUS_MAP[l.status] || { label: l.status, color: 'bg-gray-100' }
return (
<div key={l.id} className="flex justify-between items-center py-3 border-b last:border-0">
<div>
<p className="font-medium">
{l.leaveType === 'ANNUAL' ? 'سنوية' : l.leaveType === 'SICK' ? 'مرضية' : l.leaveType} - {l.days} يوم
</p>
<p className="text-sm text-gray-600">
{new Date(l.startDate).toLocaleDateString('ar-SA')} - {new Date(l.endDate).toLocaleDateString('ar-SA')}
</p>
{l.rejectedReason && <p className="text-sm text-red-600">سبب الرفض: {l.rejectedReason}</p>}
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
</div>
)
})}
</div>
)}
</div>
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب إجازة جديد">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">نوع الإجازة</label>
<select
value={form.leaveType}
onChange={(e) => setForm((p) => ({ ...p, leaveType: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
>
{LEAVE_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">من تاريخ</label>
<input
type="date"
value={form.startDate}
onChange={(e) => setForm((p) => ({ ...p, startDate: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">إلى تاريخ</label>
<input
type="date"
value={form.endDate}
onChange={(e) => setForm((p) => ({ ...p, endDate: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">السبب</label>
<textarea
value={form.reason}
onChange={(e) => setForm((p) => ({ ...p, reason: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
rows={3}
/>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setShowModal(false)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">
إلغاء
</button>
<button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50">
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
</button>
</div>
</form>
</Modal>
</div>
)
}