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:
@@ -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: 'التسويق',
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
94
frontend/src/app/portal/attendance/page.tsx
Normal file
94
frontend/src/app/portal/attendance/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
107
frontend/src/app/portal/layout.tsx
Normal file
107
frontend/src/app/portal/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
179
frontend/src/app/portal/loans/page.tsx
Normal file
179
frontend/src/app/portal/loans/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
158
frontend/src/app/portal/page.tsx
Normal file
158
frontend/src/app/portal/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
210
frontend/src/app/portal/purchase-requests/page.tsx
Normal file
210
frontend/src/app/portal/purchase-requests/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
frontend/src/app/portal/salaries/page.tsx
Normal file
65
frontend/src/app/portal/salaries/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
frontend/src/lib/api/hrAdmin.ts
Normal file
76
frontend/src/lib/api/hrAdmin.ts
Normal 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),
|
||||
}
|
||||
163
frontend/src/lib/api/portal.ts
Normal file
163
frontend/src/lib/api/portal.ts
Normal 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 || []
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user