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:
189
frontend/src/app/portal/leave/page.tsx
Normal file
189
frontend/src/app/portal/leave/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user