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

@@ -8,6 +8,7 @@ import LanguageSwitcher from '@/components/LanguageSwitcher'
import Link from 'next/link'
import {
Users,
User,
TrendingUp,
Package,
CheckSquare,
@@ -85,6 +86,16 @@ function DashboardContent() {
description: 'الموظفين والإجازات والرواتب',
permission: 'hr'
},
{
id: 'portal',
name: 'البوابة الذاتية',
nameEn: 'My Portal',
icon: User,
color: 'bg-cyan-500',
href: '/portal',
description: 'قروضي، إجازاتي، طلبات الشراء والرواتب',
permission: 'hr'
},
{
id: 'marketing',
name: 'التسويق',

View File

@@ -24,10 +24,14 @@ import {
User,
CheckCircle2,
XCircle,
Network
Network,
Banknote,
ShoppingCart,
FileText
} from 'lucide-react'
import dynamic from 'next/dynamic'
import { employeesAPI, Employee, CreateEmployeeData, UpdateEmployeeData, EmployeeFilters, departmentsAPI, positionsAPI, type Department } from '@/lib/api/employees'
import { hrAdminAPI } from '@/lib/api/hrAdmin'
const OrgChart = dynamic(() => import('@/components/hr/OrgChart'), { ssr: false })
@@ -291,8 +295,8 @@ function HRContent() {
const [positions, setPositions] = useState<any[]>([])
const [loadingDepts, setLoadingDepts] = useState(false)
// Tabs: employees | departments | orgchart
const [activeTab, setActiveTab] = useState<'employees' | 'departments' | 'orgchart'>('employees')
// Tabs: employees | departments | orgchart | leaves | loans | purchases | contracts
const [activeTab, setActiveTab] = useState<'employees' | 'departments' | 'orgchart' | 'leaves' | 'loans' | 'purchases' | 'contracts'>('employees')
const [hierarchy, setHierarchy] = useState<Department[]>([])
const [loadingHierarchy, setLoadingHierarchy] = useState(false)
@@ -303,6 +307,13 @@ function HRContent() {
const [deptFormErrors, setDeptFormErrors] = useState<Record<string, string>>({})
const [deptDeleteConfirm, setDeptDeleteConfirm] = useState<string | null>(null)
// HR Admin tabs data
const [leavesData, setLeavesData] = useState<any[]>([])
const [loansData, setLoansData] = useState<any[]>([])
const [purchasesData, setPurchasesData] = useState<any[]>([])
const [contractsData, setContractsData] = useState<any[]>([])
const [loadingHRTab, setLoadingHRTab] = useState(false)
const fetchDepartments = useCallback(async () => {
setLoadingDepts(true)
try {
@@ -352,6 +363,34 @@ function HRContent() {
if (activeTab === 'orgchart') fetchHierarchy()
}, [activeTab, fetchDepartments, fetchHierarchy])
useEffect(() => {
if (activeTab === 'leaves' || activeTab === 'loans' || activeTab === 'purchases' || activeTab === 'contracts') {
setLoadingHRTab(true)
const load = async () => {
try {
if (activeTab === 'leaves') {
const { leaves } = await hrAdminAPI.getLeaves({ status: 'PENDING', pageSize: 50 })
setLeavesData(leaves)
} else if (activeTab === 'loans') {
const { loans } = await hrAdminAPI.getLoans({ status: 'PENDING', pageSize: 50 })
setLoansData(loans)
} else if (activeTab === 'purchases') {
const { purchaseRequests } = await hrAdminAPI.getPurchaseRequests({ status: 'PENDING', pageSize: 50 })
setPurchasesData(purchaseRequests)
} else if (activeTab === 'contracts') {
const { contracts } = await hrAdminAPI.getContracts({ pageSize: 50 })
setContractsData(contracts)
}
} catch {
toast.error('Failed to load data')
} finally {
setLoadingHRTab(false)
}
}
load()
}
}, [activeTab])
// Fetch Employees (with debouncing for search)
const fetchEmployees = useCallback(async () => {
setLoading(true)
@@ -720,6 +759,58 @@ function HRContent() {
الهيكل التنظيمي / Org Chart
</span>
</button>
<button
onClick={() => setActiveTab('leaves')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'leaves'
? 'border-red-600 text-red-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<span className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
الإجازات / Leaves
</span>
</button>
<button
onClick={() => setActiveTab('loans')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'loans'
? 'border-red-600 text-red-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<span className="flex items-center gap-2">
<Banknote className="h-4 w-4" />
القروض / Loans
</span>
</button>
<button
onClick={() => setActiveTab('purchases')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'purchases'
? 'border-red-600 text-red-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<span className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4" />
طلبات الشراء / Purchases
</span>
</button>
<button
onClick={() => setActiveTab('contracts')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'contracts'
? 'border-red-600 text-red-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<span className="flex items-center gap-2">
<FileText className="h-4 w-4" />
العقود / Contracts
</span>
</button>
</nav>
</div>
</div>
@@ -1061,6 +1152,108 @@ function HRContent() {
)}
</div>
)}
{activeTab === 'leaves' && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold mb-4">طلبات الإجازة المعلقة / Pending Leave Requests</h2>
{loadingHRTab ? <LoadingSpinner /> : leavesData.length === 0 ? (
<p className="text-gray-500">No pending leaves</p>
) : (
<div className="space-y-3">
{leavesData.map((l: any) => (
<div key={l.id} className="flex justify-between items-center py-3 border-b">
<div>
<p className="font-medium">{l.employee?.firstName} {l.employee?.lastName}</p>
<p className="text-sm text-gray-600">{l.leaveType} - {l.days} days ({new Date(l.startDate).toLocaleDateString()} - {new Date(l.endDate).toLocaleDateString()})</p>
{l.reason && <p className="text-xs text-gray-500">{l.reason}</p>}
</div>
<div className="flex gap-2">
<button onClick={async () => { try { await hrAdminAPI.approveLeave(l.id); toast.success('Approved'); setLeavesData((p: any[]) => p.filter((x: any) => x.id !== l.id)) } catch { toast.error('Failed') } }} className="px-3 py-1 bg-green-600 text-white rounded text-sm">Approve</button>
<button onClick={async () => { const r = prompt('Rejection reason?'); if (r) { try { await hrAdminAPI.rejectLeave(l.id, r); toast.success('Rejected'); setLeavesData((p: any[]) => p.filter((x: any) => x.id !== l.id)) } catch { toast.error('Failed') } } }} className="px-3 py-1 bg-red-600 text-white rounded text-sm">Reject</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'loans' && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold mb-4">طلبات القروض المعلقة / Pending Loan Requests</h2>
{loadingHRTab ? <LoadingSpinner /> : loansData.length === 0 ? (
<p className="text-gray-500">No pending loans</p>
) : (
<div className="space-y-3">
{loansData.map((l: any) => (
<div key={l.id} className="flex justify-between items-center py-3 border-b">
<div>
<p className="font-medium">{l.employee?.firstName} {l.employee?.lastName}</p>
<p className="text-sm text-gray-600">{l.loanNumber} - {l.type} - {Number(l.amount).toLocaleString()} SAR ({l.installments} installments)</p>
{l.reason && <p className="text-xs text-gray-500">{l.reason}</p>}
</div>
<div className="flex gap-2">
<button onClick={async () => { try { await hrAdminAPI.approveLoan(l.id); toast.success('Approved'); setLoansData((p: any[]) => p.filter((x: any) => x.id !== l.id)) } catch { toast.error('Failed') } }} className="px-3 py-1 bg-green-600 text-white rounded text-sm">Approve</button>
<button onClick={async () => { const r = prompt('Rejection reason?'); if (r) { try { await hrAdminAPI.rejectLoan(l.id, r); toast.success('Rejected'); setLoansData((p: any[]) => p.filter((x: any) => x.id !== l.id)) } catch { toast.error('Failed') } } }} className="px-3 py-1 bg-red-600 text-white rounded text-sm">Reject</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'purchases' && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold mb-4">طلبات الشراء المعلقة / Pending Purchase Requests</h2>
{loadingHRTab ? <LoadingSpinner /> : purchasesData.length === 0 ? (
<p className="text-gray-500">No pending purchase requests</p>
) : (
<div className="space-y-3">
{purchasesData.map((pr: any) => (
<div key={pr.id} className="flex justify-between items-center py-3 border-b">
<div>
<p className="font-medium">{pr.employee?.firstName} {pr.employee?.lastName}</p>
<p className="text-sm text-gray-600">{pr.requestNumber} - {pr.totalAmount != null ? Number(pr.totalAmount).toLocaleString() + ' SAR' : '-'}</p>
{pr.reason && <p className="text-xs text-gray-500">{pr.reason}</p>}
</div>
<div className="flex gap-2">
<button onClick={async () => { try { await hrAdminAPI.approvePurchaseRequest(pr.id); toast.success('Approved'); setPurchasesData((p: any[]) => p.filter((x: any) => x.id !== pr.id)) } catch { toast.error('Failed') } }} className="px-3 py-1 bg-green-600 text-white rounded text-sm">Approve</button>
<button onClick={async () => { const r = prompt('Rejection reason?'); if (r) { try { await hrAdminAPI.rejectPurchaseRequest(pr.id, r); toast.success('Rejected'); setPurchasesData((p: any[]) => p.filter((x: any) => x.id !== pr.id)) } catch { toast.error('Failed') } } }} className="px-3 py-1 bg-red-600 text-white rounded text-sm">Reject</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'contracts' && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold mb-4">عقود الموظفين / Employee Contracts</h2>
{loadingHRTab ? <LoadingSpinner /> : contractsData.length === 0 ? (
<p className="text-gray-500">No contracts</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="border-b"><th className="text-right py-2">Contract</th><th className="text-right py-2">Employee</th><th className="text-right py-2">Type</th><th className="text-right py-2">Salary</th><th className="text-right py-2">Period</th><th className="text-right py-2">Status</th></tr></thead>
<tbody>
{contractsData.map((c: any) => (
<tr key={c.id} className="border-b">
<td className="py-2">{c.contractNumber}</td>
<td className="py-2">{c.employee?.firstName} {c.employee?.lastName}</td>
<td className="py-2">{c.type}</td>
<td className="py-2">{Number(c.salary).toLocaleString()} SAR</td>
<td className="py-2">{new Date(c.startDate).toLocaleDateString()}{c.endDate ? ' - ' + new Date(c.endDate).toLocaleDateString() : ''}</td>
<td className="py-2"><span className={`px-2 py-0.5 rounded text-xs ${c.status === 'ACTIVE' ? 'bg-green-100' : 'bg-gray-100'}`}>{c.status}</span></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</main>
{/* Create Modal */}

View File

@@ -0,0 +1,94 @@
'use client'
import { useState, useEffect } from 'react'
import { portalAPI, type Attendance } from '@/lib/api/portal'
import LoadingSpinner from '@/components/LoadingSpinner'
import { Clock } from 'lucide-react'
export default function PortalAttendancePage() {
const [attendance, setAttendance] = useState<Attendance[]>([])
const [loading, setLoading] = useState(true)
const [month, setMonth] = useState(new Date().getMonth() + 1)
const [year, setYear] = useState(new Date().getFullYear())
useEffect(() => {
setLoading(true)
portalAPI.getAttendance(month, year)
.then(setAttendance)
.catch(() => setAttendance([]))
.finally(() => setLoading(false))
}, [month, year])
const months = Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleDateString('ar-SA', { month: 'long' }) }))
const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i)
if (loading) return <LoadingSpinner />
return (
<div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<h1 className="text-2xl font-bold text-gray-900">حضوري</h1>
<div className="flex gap-2">
<select
value={month}
onChange={(e) => setMonth(parseInt(e.target.value))}
className="px-3 py-2 border border-gray-300 rounded-lg"
>
{months.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
<select
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
className="px-3 py-2 border border-gray-300 rounded-lg"
>
{years.map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
</div>
{attendance.length === 0 ? (
<div className="bg-white rounded-xl shadow p-12 text-center text-gray-500">
<Clock className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>لا توجد سجلات حضور لهذا الشهر</p>
</div>
) : (
<div className="bg-white rounded-xl shadow overflow-hidden border border-gray-100">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="text-right py-3 px-4">التاريخ</th>
<th className="text-right py-3 px-4">دخول</th>
<th className="text-right py-3 px-4">خروج</th>
<th className="text-right py-3 px-4">ساعات العمل</th>
<th className="text-right py-3 px-4">الحالة</th>
</tr>
</thead>
<tbody>
{attendance.map((a) => (
<tr key={a.id} className="border-t">
<td className="py-3 px-4">{new Date(a.date).toLocaleDateString('ar-SA')}</td>
<td className="py-3 px-4">{a.checkIn ? new Date(a.checkIn).toLocaleTimeString('ar-SA', { hour: '2-digit', minute: '2-digit' }) : '-'}</td>
<td className="py-3 px-4">{a.checkOut ? new Date(a.checkOut).toLocaleTimeString('ar-SA', { hour: '2-digit', minute: '2-digit' }) : '-'}</td>
<td className="py-3 px-4">{a.workHours != null ? Number(a.workHours).toFixed(1) : '-'}</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 rounded text-xs ${
a.status === 'PRESENT' ? 'bg-green-100 text-green-800' :
a.status === 'ABSENT' ? 'bg-red-100 text-red-800' :
a.status === 'LATE' ? 'bg-amber-100 text-amber-800' : 'bg-gray-100'
}`}>
{a.status === 'PRESENT' ? 'حاضر' : a.status === 'ABSENT' ? 'غائب' : a.status === 'LATE' ? 'متأخر' : a.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,107 @@
'use client'
import ProtectedRoute from '@/components/ProtectedRoute'
import { useAuth } from '@/contexts/AuthContext'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import {
LayoutDashboard,
Banknote,
Calendar,
ShoppingCart,
Clock,
DollarSign,
Building2,
LogOut,
User
} from 'lucide-react'
function PortalLayoutContent({ children }: { children: React.ReactNode }) {
const { user, logout } = useAuth()
const pathname = usePathname()
const menuItems = [
{ icon: LayoutDashboard, label: 'لوحة التحكم', labelEn: 'Dashboard', href: '/portal', exact: true },
{ icon: Banknote, label: 'قروضي', labelEn: 'My Loans', href: '/portal/loans' },
{ icon: Calendar, label: 'إجازاتي', labelEn: 'My Leave', href: '/portal/leave' },
{ icon: ShoppingCart, label: 'طلبات الشراء', labelEn: 'Purchase Requests', href: '/portal/purchase-requests' },
{ icon: Clock, label: 'حضوري', labelEn: 'My Attendance', href: '/portal/attendance' },
{ icon: DollarSign, label: 'رواتبي', labelEn: 'My Salaries', href: '/portal/salaries' },
]
const isActive = (href: string, exact?: boolean) => {
if (exact) return pathname === href
return pathname.startsWith(href)
}
return (
<div className="min-h-screen bg-gray-50 flex" dir="rtl">
<aside className="w-64 bg-white border-l shadow-lg fixed h-full overflow-y-auto">
<div className="p-6 border-b">
<div className="flex items-center gap-3 mb-4">
<div className="bg-teal-600 p-2 rounded-lg">
<User className="h-6 w-6 text-white" />
</div>
<div>
<h2 className="text-lg font-bold text-gray-900">البوابة الذاتية</h2>
<p className="text-xs text-gray-600">Employee Portal</p>
</div>
</div>
<div className="bg-teal-50 border border-teal-200 rounded-lg p-3">
<p className="text-xs font-semibold text-teal-900">{user?.username}</p>
<p className="text-xs text-teal-700">{user?.role?.name || 'موظف'}</p>
</div>
</div>
<nav className="p-4">
{menuItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href, item.exact)
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-4 py-3 rounded-lg mb-2 transition-all ${
active ? 'bg-teal-600 text-white shadow-md' : 'text-gray-700 hover:bg-gray-100'
}`}
>
<Icon className="h-5 w-5" />
<span className="font-medium">{item.label}</span>
</Link>
)
})}
<hr className="my-4 border-gray-200" />
<Link
href="/dashboard"
className="flex items-center gap-3 px-4 py-3 rounded-lg mb-2 text-gray-700 hover:bg-gray-100 transition-all"
>
<Building2 className="h-5 w-5" />
<span className="font-medium">العودة للنظام</span>
</Link>
<button
onClick={logout}
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg text-red-600 hover:bg-red-50 transition-all"
>
<LogOut className="h-5 w-5" />
<span className="font-medium">تسجيل الخروج</span>
</button>
</nav>
</aside>
<main className="mr-64 flex-1 p-8">
{children}
</main>
</div>
)
}
export default function PortalLayout({ children }: { children: React.ReactNode }) {
return (
<ProtectedRoute>
<PortalLayoutContent>{children}</PortalLayoutContent>
</ProtectedRoute>
)
}

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>
)
}

View File

@@ -0,0 +1,179 @@
'use client'
import { useState, useEffect } from 'react'
import { portalAPI, type Loan } from '@/lib/api/portal'
import Modal from '@/components/Modal'
import LoadingSpinner from '@/components/LoadingSpinner'
import { toast } from 'react-hot-toast'
import { Banknote, Plus, ChevronLeft } from 'lucide-react'
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' },
ACTIVE: { label: 'نشط', color: 'bg-blue-100 text-blue-800' },
PAID_OFF: { label: 'مسدد', color: 'bg-gray-100 text-gray-800' },
}
export default function PortalLoansPage() {
const [loans, setLoans] = useState<Loan[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [form, setForm] = useState({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' })
useEffect(() => {
portalAPI.getLoans()
.then(setLoans)
.catch(() => toast.error('فشل تحميل القروض'))
.finally(() => setLoading(false))
}, [])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const amount = parseFloat(form.amount)
if (!amount || amount <= 0) {
toast.error('أدخل مبلغاً صالحاً')
return
}
setSubmitting(true)
portalAPI.submitLoanRequest({
type: form.type,
amount,
installments: parseInt(form.installments) || 1,
reason: form.reason || undefined,
})
.then((loan) => {
setLoans((prev) => [loan, ...prev])
setShowModal(false)
setForm({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' })
toast.success('تم إرسال طلب القرض')
})
.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>
{loans.length === 0 ? (
<div className="bg-white rounded-xl shadow p-12 text-center text-gray-500">
<Banknote className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>لا توجد قروض</p>
<button onClick={() => setShowModal(true)} className="mt-4 text-teal-600 hover:underline">
تقديم طلب قرض
</button>
</div>
) : (
<div className="space-y-4">
{loans.map((loan) => {
const statusInfo = STATUS_MAP[loan.status] || { label: loan.status, color: 'bg-gray-100 text-gray-800' }
return (
<div key={loan.id} className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex justify-between items-start">
<div>
<p className="font-semibold text-gray-900">{loan.loanNumber}</p>
<p className="text-sm text-gray-600 mt-1">
{loan.type === 'SALARY_ADVANCE' ? 'سلفة راتب' : loan.type} - {Number(loan.amount).toLocaleString()} ر.س
</p>
<p className="text-xs text-gray-500 mt-1">
{loan.installments} أقساط × {loan.monthlyAmount ? Number(loan.monthlyAmount).toLocaleString() : '-'} ر.س
</p>
{loan.installmentsList && loan.installmentsList.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{loan.installmentsList.map((i) => (
<span
key={i.id}
className={`text-xs px-2 py-1 rounded ${
i.status === 'PAID' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'
}`}
>
{i.installmentNumber}: {i.status === 'PAID' ? 'مسدد' : 'معلق'}
</span>
))}
</div>
)}
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
</div>
{loan.rejectedReason && (
<p className="mt-2 text-sm text-red-600">سبب الرفض: {loan.rejectedReason}</p>
)}
</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.type}
onChange={(e) => setForm((p) => ({ ...p, type: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
>
<option value="SALARY_ADVANCE">سلفة راتب</option>
<option value="EQUIPMENT">معدات</option>
<option value="PERSONAL">شخصي</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">المبلغ (ر.س) *</label>
<input
type="number"
min="1"
step="0.01"
value={form.amount}
onChange={(e) => setForm((p) => ({ ...p, amount: 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="number"
min="1"
value={form.installments}
onChange={(e) => setForm((p) => ({ ...p, installments: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</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>
)
}

View File

@@ -0,0 +1,158 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { portalAPI, type PortalProfile } from '@/lib/api/portal'
import LoadingSpinner from '@/components/LoadingSpinner'
import { Banknote, Calendar, ShoppingCart, Clock, Plus, ArrowLeft } from 'lucide-react'
import { toast } from 'react-hot-toast'
export default function PortalDashboardPage() {
const [data, setData] = useState<PortalProfile | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
portalAPI.getMe()
.then(setData)
.catch(() => toast.error('فشل تحميل البيانات'))
.finally(() => setLoading(false))
}, [])
if (loading) return <LoadingSpinner />
if (!data) return <div className="text-center text-gray-500 py-12">لا توجد بيانات</div>
const { employee, stats } = data
const name = employee.firstNameAr && employee.lastNameAr
? `${employee.firstNameAr} ${employee.lastNameAr}`
: `${employee.firstName} ${employee.lastName}`
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">مرحباً، {name}</h1>
<p className="text-gray-600 mt-1">
{employee.department?.nameAr || employee.department?.name} - {employee.position?.titleAr || employee.position?.title}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">رصيد الإجازات</p>
<p className="text-2xl font-bold text-teal-600 mt-1">
{stats.leaveBalance.reduce((s, b) => s + b.available, 0)} يوم
</p>
</div>
<div className="bg-teal-100 p-3 rounded-lg">
<Calendar className="h-6 w-6 text-teal-600" />
</div>
</div>
<Link href="/portal/leave" className="mt-4 text-sm text-teal-600 hover:underline flex items-center gap-1">
عرض التفاصيل <ArrowLeft className="h-4 w-4" />
</Link>
</div>
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">القروض النشطة</p>
<p className="text-2xl font-bold text-amber-600 mt-1">{stats.activeLoansCount}</p>
</div>
<div className="bg-amber-100 p-3 rounded-lg">
<Banknote className="h-6 w-6 text-amber-600" />
</div>
</div>
<Link href="/portal/loans" className="mt-4 text-sm text-amber-600 hover:underline flex items-center gap-1">
عرض القروض <ArrowLeft className="h-4 w-4" />
</Link>
</div>
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">طلبات قيد المراجعة</p>
<p className="text-2xl font-bold text-blue-600 mt-1">
{stats.pendingLeavesCount + stats.pendingPurchaseRequestsCount}
</p>
</div>
<div className="bg-blue-100 p-3 rounded-lg">
<ShoppingCart className="h-6 w-6 text-blue-600" />
</div>
</div>
<div className="mt-4 flex gap-4">
<Link href="/portal/leave" className="text-sm text-blue-600 hover:underline">الإجازات</Link>
<Link href="/portal/purchase-requests" className="text-sm text-blue-600 hover:underline">الشراء</Link>
</div>
</div>
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">حضوري</p>
<p className="text-sm text-gray-500 mt-1">هذا الشهر</p>
</div>
<div className="bg-gray-100 p-3 rounded-lg">
<Clock className="h-6 w-6 text-gray-600" />
</div>
</div>
<Link href="/portal/attendance" className="mt-4 text-sm text-gray-600 hover:underline flex items-center gap-1">
عرض الحضور <ArrowLeft className="h-4 w-4" />
</Link>
</div>
</div>
{stats.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="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-right py-2">نوع الإجازة</th>
<th className="text-right py-2">الإجمالي</th>
<th className="text-right py-2">المستخدم</th>
<th className="text-right py-2">المتبقي</th>
</tr>
</thead>
<tbody>
{stats.leaveBalance.map((b) => (
<tr key={b.leaveType} className="border-b last:border-0">
<td className="py-2">{b.leaveType === 'ANNUAL' ? 'سنوية' : b.leaveType === 'SICK' ? 'مرضية' : b.leaveType}</td>
<td className="py-2">{b.totalDays + b.carriedOver}</td>
<td className="py-2">{b.usedDays}</td>
<td className="py-2 font-semibold text-teal-600">{b.available}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="flex flex-wrap gap-4">
<Link
href="/portal/loans"
className="inline-flex items-center gap-2 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700"
>
<Plus className="h-4 w-4" />
طلب قرض
</Link>
<Link
href="/portal/leave"
className="inline-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" />
طلب إجازة
</Link>
<Link
href="/portal/purchase-requests"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus className="h-4 w-4" />
طلب شراء
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,210 @@
'use client'
import { useState, useEffect } from 'react'
import { portalAPI, type PurchaseRequest } from '@/lib/api/portal'
import Modal from '@/components/Modal'
import LoadingSpinner from '@/components/LoadingSpinner'
import { toast } from 'react-hot-toast'
import { ShoppingCart, Plus } from 'lucide-react'
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' },
ORDERED: { label: 'تم الطلب', color: 'bg-blue-100 text-blue-800' },
}
export default function PortalPurchaseRequestsPage() {
const [requests, setRequests] = useState<PurchaseRequest[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [form, setForm] = useState({
items: [{ description: '', quantity: 1, estimatedPrice: '' }],
reason: '',
priority: 'NORMAL',
})
useEffect(() => {
portalAPI.getPurchaseRequests()
.then(setRequests)
.catch(() => toast.error('فشل تحميل الطلبات'))
.finally(() => setLoading(false))
}, [])
const addItem = () => setForm((p) => ({ ...p, items: [...p.items, { description: '', quantity: 1, estimatedPrice: '' }] }))
const removeItem = (i: number) =>
setForm((p) => ({ ...p, items: p.items.filter((_, idx) => idx !== i) }))
const updateItem = (i: number, key: string, value: string | number) =>
setForm((p) => ({
...p,
items: p.items.map((it, idx) => (idx === i ? { ...it, [key]: value } : it)),
}))
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const items = form.items
.filter((it) => it.description.trim())
.map((it) => ({
description: it.description,
quantity: it.quantity || 1,
estimatedPrice: parseFloat(String(it.estimatedPrice)) || 0,
}))
if (items.length === 0) {
toast.error('أضف صنفاً واحداً على الأقل')
return
}
setSubmitting(true)
portalAPI.submitPurchaseRequest({
items,
reason: form.reason || undefined,
priority: form.priority,
})
.then((pr) => {
setRequests((prev) => [pr, ...prev])
setShowModal(false)
setForm({ items: [{ description: '', quantity: 1, estimatedPrice: '' }], reason: '', priority: 'NORMAL' })
toast.success('تم إرسال طلب الشراء')
})
.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>
{requests.length === 0 ? (
<div className="bg-white rounded-xl shadow p-12 text-center text-gray-500">
<ShoppingCart className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>لا توجد طلبات شراء</p>
<button onClick={() => setShowModal(true)} className="mt-4 text-teal-600 hover:underline">
تقديم طلب شراء
</button>
</div>
) : (
<div className="space-y-4">
{requests.map((pr) => {
const statusInfo = STATUS_MAP[pr.status] || { label: pr.status, color: 'bg-gray-100 text-gray-800' }
const items = Array.isArray(pr.items) ? pr.items : []
return (
<div key={pr.id} className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex justify-between items-start">
<div>
<p className="font-semibold text-gray-900">{pr.requestNumber}</p>
<p className="text-sm text-gray-600 mt-1">
{pr.totalAmount != null ? `${Number(pr.totalAmount).toLocaleString()} ر.س` : '-'}
</p>
{items.length > 0 && (
<ul className="mt-2 text-sm text-gray-600 list-disc list-inside">
{items.slice(0, 3).map((it: any, i: number) => (
<li key={i}>
{it.description} × {it.quantity || 1}
{it.estimatedPrice ? ` (${Number(it.estimatedPrice).toLocaleString()} ر.س)` : ''}
</li>
))}
{items.length > 3 && <li>... و {items.length - 3} أصناف أخرى</li>}
</ul>
)}
{pr.rejectedReason && (
<p className="mt-2 text-sm text-red-600">سبب الرفض: {pr.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 max-h-[70vh] overflow-y-auto">
<div>
<div className="flex justify-between items-center mb-2">
<label className="text-sm font-medium text-gray-700">الأصناف</label>
<button type="button" onClick={addItem} className="text-teal-600 text-sm hover:underline">
+ إضافة صنف
</button>
</div>
<div className="space-y-3">
{form.items.map((it, i) => (
<div key={i} className="flex gap-2 items-start border p-2 rounded">
<input
placeholder="الوصف"
value={it.description}
onChange={(e) => updateItem(i, 'description', e.target.value)}
className="flex-1 px-2 py-1 border rounded text-sm"
/>
<input
type="number"
min="1"
placeholder="الكمية"
value={it.quantity}
onChange={(e) => updateItem(i, 'quantity', parseInt(e.target.value) || 1)}
className="w-20 px-2 py-1 border rounded text-sm"
/>
<input
type="number"
min="0"
step="0.01"
placeholder="السعر"
value={it.estimatedPrice}
onChange={(e) => updateItem(i, 'estimatedPrice', e.target.value)}
className="w-24 px-2 py-1 border rounded text-sm"
/>
<button type="button" onClick={() => removeItem(i)} className="text-red-600 text-sm">
حذف
</button>
</div>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">الأولوية</label>
<select
value={form.priority}
onChange={(e) => setForm((p) => ({ ...p, priority: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
>
<option value="LOW">منخفضة</option>
<option value="NORMAL">عادية</option>
<option value="HIGH">عالية</option>
<option value="URGENT">عاجلة</option>
</select>
</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={2}
/>
</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>
)
}

View File

@@ -0,0 +1,65 @@
'use client'
import { useState, useEffect } from 'react'
import { portalAPI, type Salary } from '@/lib/api/portal'
import LoadingSpinner from '@/components/LoadingSpinner'
import { DollarSign } from 'lucide-react'
const MONTHS_AR = ['يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر']
export default function PortalSalariesPage() {
const [salaries, setSalaries] = useState<Salary[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
portalAPI.getSalaries()
.then(setSalaries)
.catch(() => setSalaries([]))
.finally(() => setLoading(false))
}, [])
if (loading) return <LoadingSpinner />
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">رواتبي</h1>
{salaries.length === 0 ? (
<div className="bg-white rounded-xl shadow p-12 text-center text-gray-500">
<DollarSign className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>لا توجد سجلات رواتب</p>
</div>
) : (
<div className="space-y-4">
{salaries.map((s) => (
<div key={s.id} className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex justify-between items-start">
<div>
<p className="font-semibold text-gray-900">
{MONTHS_AR[s.month - 1]} {s.year}
</p>
<p className="text-2xl font-bold text-teal-600 mt-1">
{Number(s.netSalary).toLocaleString()} ر.س
</p>
<div className="mt-2 text-sm text-gray-600 space-y-1">
<p>الأساس: {Number(s.basicSalary).toLocaleString()} | البدلات: {Number(s.allowances).toLocaleString()} | الخصومات: {Number(s.deductions).toLocaleString()}</p>
<p>عمولة: {Number(s.commissions).toLocaleString()} | إضافي: {Number(s.overtimePay).toLocaleString()}</p>
</div>
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
s.status === 'PAID' ? 'bg-green-100 text-green-800' :
s.status === 'APPROVED' ? 'bg-blue-100 text-blue-800' : 'bg-amber-100 text-amber-800'
}`}>
{s.status === 'PAID' ? 'مدفوع' : s.status === 'APPROVED' ? 'معتمد' : 'قيد المعالجة'}
</span>
</div>
{s.paidDate && (
<p className="text-xs text-gray-500 mt-2">تاريخ الدفع: {new Date(s.paidDate).toLocaleDateString('ar-SA')}</p>
)}
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,76 @@
import { api } from '../api'
export const hrAdminAPI = {
// Leaves
getLeaves: async (params?: { employeeId?: string; status?: string; page?: number; pageSize?: number }) => {
const q = new URLSearchParams()
if (params?.employeeId) q.append('employeeId', params.employeeId)
if (params?.status) q.append('status', params.status)
if (params?.page) q.append('page', String(params.page))
if (params?.pageSize) q.append('pageSize', String(params.pageSize))
const res = await api.get(`/hr/leaves?${q}`)
return { leaves: res.data.data || [], pagination: res.data.pagination }
},
approveLeave: (id: string) => api.post(`/hr/leaves/${id}/approve`),
rejectLeave: (id: string, rejectedReason: string) => api.post(`/hr/leaves/${id}/reject`, { rejectedReason }),
// Loans
getLoans: async (params?: { employeeId?: string; status?: string; page?: number; pageSize?: number }) => {
const q = new URLSearchParams()
if (params?.employeeId) q.append('employeeId', params.employeeId)
if (params?.status) q.append('status', params.status)
if (params?.page) q.append('page', String(params.page))
if (params?.pageSize) q.append('pageSize', String(params.pageSize))
const res = await api.get(`/hr/loans?${q}`)
return { loans: res.data.data || [], pagination: res.data.pagination }
},
getLoanById: (id: string) => api.get(`/hr/loans/${id}`),
createLoan: (data: { employeeId: string; type: string; amount: number; installments?: number; reason?: string }) =>
api.post('/hr/loans', data),
approveLoan: (id: string, startDate?: string) => api.post(`/hr/loans/${id}/approve`, { startDate: startDate || new Date().toISOString().split('T')[0] }),
rejectLoan: (id: string, rejectedReason: string) => api.post(`/hr/loans/${id}/reject`, { rejectedReason }),
payInstallment: (loanId: string, installmentId: string, paidDate?: string) =>
api.post(`/hr/loans/${loanId}/pay-installment`, { installmentId, paidDate: paidDate || new Date().toISOString().split('T')[0] }),
// Purchase Requests
getPurchaseRequests: async (params?: { employeeId?: string; status?: string; page?: number; pageSize?: number }) => {
const q = new URLSearchParams()
if (params?.employeeId) q.append('employeeId', params.employeeId)
if (params?.status) q.append('status', params.status)
if (params?.page) q.append('page', String(params.page))
if (params?.pageSize) q.append('pageSize', String(params.pageSize))
const res = await api.get(`/hr/purchase-requests?${q}`)
return { purchaseRequests: res.data.data || [], pagination: res.data.pagination }
},
approvePurchaseRequest: (id: string) => api.post(`/hr/purchase-requests/${id}/approve`),
rejectPurchaseRequest: (id: string, rejectedReason: string) => api.post(`/hr/purchase-requests/${id}/reject`, { rejectedReason }),
// Leave Entitlements
getLeaveBalance: (employeeId: string, year?: number) => {
const q = year ? `?year=${year}` : ''
return api.get(`/hr/leave-balance/${employeeId}${q}`).then((r) => r.data.data)
},
getLeaveEntitlements: (params?: { employeeId?: string; year?: number }) => {
const q = new URLSearchParams()
if (params?.employeeId) q.append('employeeId', params.employeeId)
if (params?.year) q.append('year', String(params.year))
return api.get(`/hr/leave-entitlements?${q}`).then((r) => r.data.data)
},
upsertLeaveEntitlement: (data: { employeeId: string; year: number; leaveType: string; totalDays: number; carriedOver?: number; notes?: string }) =>
api.post('/hr/leave-entitlements', data),
// Employee Contracts
getContracts: async (params?: { employeeId?: string; status?: string; page?: number; pageSize?: number }) => {
const q = new URLSearchParams()
if (params?.employeeId) q.append('employeeId', params.employeeId)
if (params?.status) q.append('status', params.status)
if (params?.page) q.append('page', String(params.page))
if (params?.pageSize) q.append('pageSize', String(params.pageSize))
const res = await api.get(`/hr/contracts?${q}`)
return { contracts: res.data.data || [], pagination: res.data.pagination }
},
createContract: (data: { employeeId: string; type: string; startDate: string; endDate?: string; salary: number; documentUrl?: string; notes?: string }) =>
api.post('/hr/contracts', data),
updateContract: (id: string, data: Partial<{ type: string; endDate: string; salary: number; status: string; notes: string }>) =>
api.put(`/hr/contracts/${id}`, data),
}

View File

@@ -0,0 +1,163 @@
import { api } from '../api'
export interface PortalProfile {
employee: {
id: string
uniqueEmployeeId: string
firstName: string
lastName: string
firstNameAr?: string | null
lastNameAr?: string | null
email: string
department?: { name: string; nameAr?: string | null }
position?: { title: string; titleAr?: string | null }
}
stats: {
activeLoansCount: number
pendingLeavesCount: number
pendingPurchaseRequestsCount: number
leaveBalance: Array<{
leaveType: string
totalDays: number
carriedOver: number
usedDays: number
available: number
}>
}
}
export interface Loan {
id: string
loanNumber: string
type: string
amount: number
currency: string
installments: number
monthlyAmount?: number
reason?: string | null
status: string
approvedBy?: string | null
approvedAt?: string | null
rejectedReason?: string | null
startDate?: string | null
endDate?: string | null
createdAt: string
installmentsList?: Array<{
id: string
installmentNumber: number
dueDate: string
amount: number
paidDate?: string | null
status: string
}>
}
export interface Leave {
id: string
leaveType: string
startDate: string
endDate: string
days: number
reason?: string | null
status: string
approvedBy?: string | null
approvedAt?: string | null
rejectedReason?: string | null
createdAt: string
}
export interface PurchaseRequest {
id: string
requestNumber: string
items: any[]
totalAmount?: number | null
reason?: string | null
priority: string
status: string
approvedBy?: string | null
approvedAt?: string | null
rejectedReason?: string | null
createdAt: string
}
export interface Attendance {
id: string
date: string
checkIn?: string | null
checkOut?: string | null
workHours?: number | null
overtimeHours?: number | null
status: string
}
export interface Salary {
id: string
month: number
year: number
basicSalary: number
allowances: number
deductions: number
commissions: number
overtimePay: number
netSalary: number
status: string
paidDate?: string | null
createdAt: string
}
export const portalAPI = {
getMe: async (): Promise<PortalProfile> => {
const response = await api.get('/hr/portal/me')
return response.data.data
},
getLoans: async (): Promise<Loan[]> => {
const response = await api.get('/hr/portal/loans')
return response.data.data || []
},
submitLoanRequest: async (data: { type: string; amount: number; installments?: number; reason?: string }): Promise<Loan> => {
const response = await api.post('/hr/portal/loans', data)
return response.data.data
},
getLeaveBalance: async (year?: number): Promise<PortalProfile['stats']['leaveBalance']> => {
const params = year ? `?year=${year}` : ''
const response = await api.get(`/hr/portal/leave-balance${params}`)
return response.data.data || []
},
getLeaves: async (): Promise<Leave[]> => {
const response = await api.get('/hr/portal/leaves')
return response.data.data || []
},
submitLeaveRequest: async (data: { leaveType: string; startDate: string; endDate: string; reason?: string }): Promise<Leave> => {
const response = await api.post('/hr/portal/leaves', data)
return response.data.data
},
getPurchaseRequests: async (): Promise<PurchaseRequest[]> => {
const response = await api.get('/hr/portal/purchase-requests')
return response.data.data || []
},
submitPurchaseRequest: async (data: { items: Array<{ description: string; quantity?: number; estimatedPrice?: number }>; reason?: string; priority?: string }): Promise<PurchaseRequest> => {
const response = await api.post('/hr/portal/purchase-requests', data)
return response.data.data
},
getAttendance: async (month?: number, year?: number): Promise<Attendance[]> => {
const params = new URLSearchParams()
if (month) params.append('month', String(month))
if (year) params.append('year', String(year))
const query = params.toString() ? `?${params.toString()}` : ''
const response = await api.get(`/hr/portal/attendance${query}`)
return response.data.data || []
},
getSalaries: async (): Promise<Salary[]> => {
const response = await api.get('/hr/portal/salaries')
return response.data.data || []
},
}