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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user