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

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

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

View File

@@ -0,0 +1,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>
)
}