Files
zerp/frontend/src/app/portal/purchase-requests/page.tsx
Talal Sharabi 72ed9a2ff5 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
2026-03-04 19:44:09 +04:00

211 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
)
}