addition expense claims

This commit is contained in:
Aya
2026-04-22 11:36:47 +03:00
parent e262d8c09c
commit 0a9e1bbd4d
16 changed files with 1553 additions and 31 deletions

View File

@@ -10,7 +10,7 @@ import LoadingSpinner from '@/components/LoadingSpinner';
const MODULES = [
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
{ id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' },
{ id: 'tenders', name: 'إدارة الٍٍٍمناقصات', nameEn: 'Tender Management' },
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
@@ -23,6 +23,7 @@ const MODULES = [
{ id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' },
{ id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية للقسم', nameEn: 'Department Overtime Requests' },
{ id: 'department_expense_claims', name: 'طلبات كشف المصاريف للقسم', nameEn: 'Department Expense Claims' },
{ id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },

View File

@@ -24,6 +24,7 @@ const MODULES = [
{ id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' },
{ id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية للقسم', nameEn: 'Department Overtime Requests' },
{ id: 'department_expense_claims', name: 'طلبات كشف المصاريف للقسم', nameEn: 'Department Expense Claims' },
{ id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },

View File

@@ -95,23 +95,23 @@ function DashboardContent() {
const resolveNotificationUrl = (notification: any) => {
if (notification.entityType === 'LEAVE') {
if (notification.type === 'LEAVE_REQUEST_SUBMITTED') {
return '/portal/managed-leaves'
return '/portal/managed-leaves';
}
return '/portal/leave'
return '/portal/leave';
}
if (notification.entityType === 'OVERTIME_REQUEST') {
if (notification.type === 'OVERTIME_REQUEST_SUBMITTED') {
return '/portal/managed-overtime-requests'
return '/portal/managed-overtime-requests';
}
return '/portal/overtime'
return '/portal/overtime';
}
if (notification.entityType === 'PURCHASE_REQUEST') {
if (notification.type === 'PURCHASE_REQUEST_SUBMITTED') {
return '/hr?tab=purchases'
return '/hr?tab=purchases';
}
return '/portal/purchase-requests'
return '/portal/purchase-requests';
}
if (notification.entityType === 'LOAN') {
@@ -119,31 +119,68 @@ function DashboardContent() {
notification.type === 'LOAN_REQUEST_SUBMITTED' ||
notification.type === 'LOAN_REQUEST_PENDING_ADMIN'
) {
return '/hr?tab=loans'
return '/hr?tab=loans';
}
return '/portal/loans'
return '/portal/loans';
}
if (notification.entityType === 'EXPENSE_CLAIM') {
if (notification.entityId) {
// إشعار المدير: بانتظار الموافقة
if (notification.type === 'EXPENSE_CLAIM_SUBMITTED') {
return `/portal/managed-expense-claims?claimId=${notification.entityId}`;
}
// إشعار الموظف: تم الإرسال / تمت الموافقة / تم الرفض
if (
notification.type === 'EXPENSE_CLAIM_CREATED' ||
notification.type === 'EXPENSE_CLAIM_APPROVED' ||
notification.type === 'EXPENSE_CLAIM_REJECTED'
) {
return `/portal/expense-claims?claimId=${notification.entityId}`;
}
// fallback
return `/portal/expense-claims?claimId=${notification.entityId}`;
}
return '/portal/expense-claims';
}
if (notification.type === 'TENDER_DIRECTIVE_ASSIGNED') {
if (notification.entityType === 'TENDER' && notification.entityId) {
return `/tenders/${notification.entityId}?tab=directives`
return `/tenders/${notification.entityId}?tab=directives`;
}
return '/tenders'
return '/tenders';
}
return '/dashboard'
}
return '/dashboard';
};
const handleNotificationClick = async (notification: any) => {
try {
if (!notification.isRead) {
await markNotificationAsRead(notification.id)
}
const targetUrl = resolveNotificationUrl(notification)
console.log('🔔 Notification click →', notification)
console.log('➡️ Redirecting to:', targetUrl)
setShowNotifications(false)
router.push(targetUrl)
setTimeout(() => {
window.location.href = targetUrl
}, 100)
} catch (err) {
console.error('Notification click error:', err)
}
}
const handleToggleNotifications = async () => {
const next = !showNotifications

View File

@@ -761,7 +761,7 @@ type HRTab = 'employees' | 'departments' | 'orgchart' | 'leaves' | 'loans' | 'pu
</span>
</button>
<button
onClick={() => setActiveTab('departments')}
onClick={() => openTab('departments')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'departments'
? 'border-red-600 text-red-600'
@@ -774,7 +774,7 @@ type HRTab = 'employees' | 'departments' | 'orgchart' | 'leaves' | 'loans' | 'pu
</span>
</button>
<button
onClick={() => setActiveTab('orgchart')}
onClick={() => openTab('orgchart')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'orgchart'
? 'border-red-600 text-red-600'
@@ -787,7 +787,7 @@ type HRTab = 'employees' | 'departments' | 'orgchart' | 'leaves' | 'loans' | 'pu
</span>
</button>
<button
onClick={() => setActiveTab('leaves')}
onClick={() => openTab('leaves')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'leaves'
? 'border-red-600 text-red-600'
@@ -800,7 +800,7 @@ type HRTab = 'employees' | 'departments' | 'orgchart' | 'leaves' | 'loans' | 'pu
</span>
</button>
<button
onClick={() => setActiveTab('loans')}
onClick={() => openTab('loans')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'loans'
? 'border-red-600 text-red-600'
@@ -813,7 +813,7 @@ type HRTab = 'employees' | 'departments' | 'orgchart' | 'leaves' | 'loans' | 'pu
</span>
</button>
<button
onClick={() => setActiveTab('purchases')}
onClick={() => openTab('purchases')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'purchases'
? 'border-red-600 text-red-600'
@@ -826,7 +826,7 @@ type HRTab = 'employees' | 'departments' | 'orgchart' | 'leaves' | 'loans' | 'pu
</span>
</button>
<button
onClick={() => setActiveTab('contracts')}
onClick={() => openTab('contracts')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'contracts'
? 'border-red-600 text-red-600'

View File

@@ -0,0 +1,539 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { portalAPI, type ExpenseClaim } from '@/lib/api/portal';
import Modal from '@/components/Modal';
import { useSearchParams } from 'next/navigation';
type ExpenseClaimLine = {
expenseDate: string;
amount: string;
entityName: string;
description: string;
projectOrTender: string;
proofRef: string;
};
type ExpenseClaimFormState = {
items: ExpenseClaimLine[];
description: string;
};
const emptyLine = (): ExpenseClaimLine => ({
expenseDate: '',
amount: '',
entityName: '',
description: '',
projectOrTender: '',
proofRef: '',
});
const initialForm: ExpenseClaimFormState = {
items: [emptyLine()],
description: '',
};
function getStatusLabel(status: string) {
switch (status) {
case 'PENDING':
return 'قيد المراجعة';
case 'APPROVED':
return 'مقبول';
case 'REJECTED':
return 'مرفوض';
default:
return status;
}
}
function getStatusClasses(status: string) {
switch (status) {
case 'PENDING':
return 'bg-yellow-100 text-yellow-800';
case 'APPROVED':
return 'bg-green-100 text-green-800';
case 'REJECTED':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-700';
}
}
function formatDate(value?: string | null) {
if (!value) return '-';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return d.toLocaleDateString('en-CA');
}
export default function PortalExpenseClaimsPage() {
const [claims, setClaims] = useState<ExpenseClaim[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [showModal, setShowModal] = useState(false);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState<ExpenseClaimFormState>(initialForm);
const pendingCount = useMemo(
() => claims.filter((c) => c.status === 'PENDING').length,
[claims]
);
const totalAmount = useMemo(() => {
return form.items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
}, [form.items]);
async function loadClaims() {
try {
setLoading(true);
setError(null);
const data = await portalAPI.getExpenseClaims();
setClaims(data);
} catch (err: any) {
setError(err?.response?.data?.message || 'تعذر تحميل كشوف المصاريف');
} finally {
setLoading(false);
}
}
useEffect(() => {
loadClaims();
}, []);
const addItem = () => {
setForm((prev) => ({
...prev,
items: [...prev.items, emptyLine()],
}));
};
const removeItem = (index: number) => {
setForm((prev) => ({
...prev,
items: prev.items.filter((_, i) => i !== index),
}));
};
const updateItem = (
index: number,
key: keyof ExpenseClaimLine,
value: string
) => {
setForm((prev) => ({
...prev,
items: prev.items.map((item, i) =>
i === index ? { ...item, [key]: value } : item
),
}));
};
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const items = form.items
.map((item) => ({
expenseDate: item.expenseDate,
amount: Number(item.amount || 0),
entityName: item.entityName.trim() || undefined,
description: item.description.trim(),
projectOrTender: item.projectOrTender.trim() || undefined,
proofRef: item.proofRef.trim() || undefined,
}))
.filter(
(item) =>
item.expenseDate &&
item.description &&
Number(item.amount) > 0
);
if (items.length === 0) {
alert('يرجى إضافة بند واحد على الأقل مع التاريخ والمبلغ والبيان');
return;
}
try {
setSubmitting(true);
await portalAPI.submitExpenseClaim({
items,
description: form.description.trim() || undefined,
});
setForm(initialForm);
setShowModal(false);
await loadClaims();
} catch (err: any) {
alert(err?.response?.data?.message || 'تعذر إرسال طلب كشف المصاريف');
} finally {
setSubmitting(false);
}
}
const searchParams = useSearchParams();
const claimId = searchParams.get('claimId');
return (
<div className="space-y-6" dir="rtl">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-2xl font-bold text-gray-900">كشف المصاريف</h1>
<p className="text-sm text-gray-500 mt-1">
يمكنك تقديم كشف مصاريف جديد ومتابعة حالة الطلبات السابقة
</p>
</div>
<button
onClick={() => setShowModal(true)}
className="inline-flex items-center rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700"
>
+ طلب كشف مصاريف
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="rounded-xl border bg-white p-5 shadow-sm">
<div className="text-sm text-gray-500">إجمالي الطلبات</div>
<div className="mt-2 text-2xl font-bold text-gray-900">
{claims.length}
</div>
</div>
<div className="rounded-xl border bg-white p-5 shadow-sm">
<div className="text-sm text-gray-500">قيد المراجعة</div>
<div className="mt-2 text-2xl font-bold text-yellow-600">
{pendingCount}
</div>
</div>
<div className="rounded-xl border bg-white p-5 shadow-sm">
<div className="text-sm text-gray-500">آخر تحديث</div>
<div className="mt-2 text-base font-semibold text-gray-900">
{claims[0]?.updatedAt ? formatDate(claims[0].updatedAt) : '-'}
</div>
</div>
</div>
<div className="rounded-xl border bg-white shadow-sm">
<div className="border-b px-5 py-4">
<h2 className="text-lg font-semibold text-gray-900">طلباتي</h2>
</div>
{loading ? (
<div className="p-6 text-sm text-gray-500">جاري التحميل...</div>
) : error ? (
<div className="p-6 text-sm text-red-600">{error}</div>
) : claims.length === 0 ? (
<div className="p-6 text-sm text-gray-500">
لا توجد طلبات كشف مصاريف حالياً
</div>
) : (
<div className="divide-y">
{claims.map((claim) => {
const isSelected = claim.id === claimId;
return (
<div
key={claim.id}
className={`p-5 ${
isSelected ? 'bg-yellow-50 ring-2 ring-yellow-300 rounded-lg' : ''
}`}
>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-2 w-full">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-base font-bold text-gray-900">
{claim.claimNumber}
</span>
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${getStatusClasses(
claim.status
)}`}
>
{getStatusLabel(claim.status)}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2 text-sm text-gray-600">
<div>
<span className="font-medium text-gray-800">
إجمالي المبلغ:
</span>{' '}
{claim.totalAmount ?? claim.amount ?? '-'}
</div>
<div>
<span className="font-medium text-gray-800">
تاريخ الإنشاء:
</span>{' '}
{formatDate(claim.createdAt)}
</div>
<div>
<span className="font-medium text-gray-800">
عدد البنود:
</span>{' '}
{Array.isArray(claim.items)
? claim.items.length
: claim.description
? 1
: 0}
</div>
<div>
<span className="font-medium text-gray-800">
آخر تحديث:
</span>{' '}
{formatDate(claim.updatedAt)}
</div>
</div>
{claim.description ? (
<div className="text-sm text-gray-700">
<span className="font-medium text-gray-800">
ملاحظات عامة:
</span>{' '}
{claim.description}
</div>
) : null}
{Array.isArray(claim.items) && claim.items.length > 0 ? (
<div className="mt-3 space-y-2">
<div className="text-sm font-medium text-gray-800">
البنود:
</div>
<div className="space-y-2">
{claim.items.map((item: any, idx: number) => (
<div
key={idx}
className="rounded-lg border bg-gray-50 p-3 text-sm text-gray-700"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<div>
<span className="font-medium">التاريخ:</span>{' '}
{formatDate(item.expenseDate)}
</div>
<div>
<span className="font-medium">المبلغ:</span>{' '}
{item.amount ?? '-'}
</div>
<div>
<span className="font-medium">اسم الجهة:</span>{' '}
{item.entityName || '-'}
</div>
<div>
<span className="font-medium">
المشروع / المناقصة:
</span>{' '}
{item.projectOrTender || '-'}
</div>
<div className="md:col-span-2">
<span className="font-medium">
الأوراق المثبتة:
</span>{' '}
{item.proofRef || '-'}
</div>
<div className="md:col-span-2">
<span className="font-medium">البيان:</span>{' '}
{item.description || '-'}
</div>
</div>
</div>
))}
</div>
</div>
) : (
<div className="mt-3 rounded-lg border bg-gray-50 p-3 text-sm text-gray-700">
<div>
<span className="font-medium">تاريخ المصروف:</span>{' '}
{formatDate(claim.expenseDate)}
</div>
<div>
<span className="font-medium">المبلغ:</span>{' '}
{claim.amount ?? '-'}
</div>
<div>
<span className="font-medium">
المشروع / المناقصة:
</span>{' '}
{claim.projectOrTender || '-'}
</div>
<div>
<span className="font-medium">البيان:</span>{' '}
{claim.description || '-'}
</div>
</div>
)}
{claim.status === 'REJECTED' && claim.rejectedReason ? (
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
<span className="font-medium">سبب الرفض:</span>{' '}
{claim.rejectedReason}
</div>
) : null}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
title="كشف مصاريف جديد"
>
<form onSubmit={handleSubmit} className="space-y-4">
<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((item, index) => (
<div key={index} className="border rounded-lg p-3 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
تاريخ المصروف
</label>
<input
type="date"
value={item.expenseDate}
onChange={(e) =>
updateItem(index, 'expenseDate', e.target.value)
}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
المبلغ
</label>
<input
type="number"
min="0"
step="0.01"
placeholder="المبلغ"
value={item.amount}
onChange={(e) =>
updateItem(index, 'amount', e.target.value)
}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
اسم الجهة
</label>
<input
type="text"
placeholder="اسم الجهة"
value={item.entityName}
onChange={(e) =>
updateItem(index, 'entityName', e.target.value)
}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
المشروع / المناقصة
</label>
<input
type="text"
placeholder="المشروع / المناقصة"
value={item.projectOrTender}
onChange={(e) =>
updateItem(index, 'projectOrTender', e.target.value)
}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div className="md:col-span-2">
<label className="mb-1 block text-sm font-medium text-gray-700">
البيان
</label>
<textarea
placeholder="البيان"
value={item.description}
onChange={(e) =>
updateItem(index, 'description', e.target.value)
}
className="min-h-[90px] w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</div>
</div>
{form.items.length > 1 && (
<div className="flex justify-end">
<button
type="button"
onClick={() => removeItem(index)}
className="text-sm text-red-600 hover:underline"
>
حذف البند
</button>
</div>
)}
</div>
))}
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
ملاحظات عامة
</label>
<textarea
value={form.description}
onChange={(e) =>
setForm((prev) => ({ ...prev, description: e.target.value }))
}
className="min-h-[100px] w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
placeholder="أي ملاحظات عامة على الكشف"
/>
</div>
<div className="rounded-lg bg-gray-50 border px-4 py-3 text-sm font-medium text-gray-700">
الإجمالي: {totalAmount.toLocaleString()}
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={() => setShowModal(false)}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
إلغاء
</button>
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting ? 'جارٍ الإرسال...' : 'إرسال الطلب'}
</button>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -14,6 +14,7 @@ import {
Building2,
LogOut,
User,
FileText,
CheckCircle2,
TimerReset,
} from 'lucide-react'
@@ -26,6 +27,7 @@ function PortalLayoutContent({ children }: { children: React.ReactNode }) {
{ 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: FileText, label: 'كشوف المصاريف', labelEn: 'Expense Claims', href: '/portal/expense-claims' },
{ icon: TimerReset, label: 'الساعات الإضافية', labelEn: 'Overtime', href: '/portal/overtime' },
...(hasPermission('department_overtime_requests', 'view')
? [{
@@ -35,12 +37,20 @@ function PortalLayoutContent({ children }: { children: React.ReactNode }) {
href: '/portal/managed-overtime-requests'
}]
: []),
...(hasPermission('department_leave_requests', 'view')
? [{ icon: CheckCircle2, label: 'طلبات إجازات القسم', labelEn: 'Department Leave Requests', href: '/portal/managed-leaves' }]
: []), { 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' },
]
...(hasPermission('department_expense_claims', 'view')
? [{
icon: CheckCircle2,
label: 'طلبات كشوف المصاريف',
labelEn: 'Department Expense Claims',
href: '/portal/managed-expense-claims'
}]
: []),
...(hasPermission('department_leave_requests', 'view')
? [{ icon: CheckCircle2, label: 'طلبات إجازات القسم', labelEn: 'Department Leave Requests', href: '/portal/managed-leaves' }]
: []), { 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

View File

@@ -0,0 +1,409 @@
'use client';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { portalAPI, type ExpenseClaim } from '@/lib/api/portal';
import Modal from '@/components/Modal';
function formatDate(value?: string | null) {
if (!value) return '-';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return d.toLocaleDateString('en-CA');
}
function getStatusLabel(status: string) {
switch (status) {
case 'PENDING':
return 'قيد المراجعة';
case 'APPROVED':
return 'مقبول';
case 'REJECTED':
return 'مرفوض';
default:
return status;
}
}
function getStatusClasses(status: string) {
switch (status) {
case 'PENDING':
return 'bg-yellow-100 text-yellow-800';
case 'APPROVED':
return 'bg-green-100 text-green-800';
case 'REJECTED':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-700';
}
}
export default function ManagedExpenseClaimsPage() {
const [claims, setClaims] = useState<ExpenseClaim[]>([]);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState('PENDING');
const [submittingId, setSubmittingId] = useState<string | null>(null);
const [rejectModalOpen, setRejectModalOpen] = useState(false);
const [selectedClaim, setSelectedClaim] = useState<ExpenseClaim | null>(null);
const [rejectReason, setRejectReason] = useState('');
const searchParams = useSearchParams();
const claimId = searchParams.get('claimId');
async function loadClaims(status = statusFilter) {
try {
setLoading(true);
const data = await portalAPI.getManagedExpenseClaims(
status === 'all' ? undefined : status
);
setClaims(data);
} catch (error: any) {
alert(error?.response?.data?.message || 'تعذر تحميل طلبات كشف المصاريف');
} finally {
setLoading(false);
}
}
useEffect(() => {
loadClaims(statusFilter);
}, [statusFilter]);
async function handleApprove(id: string) {
const confirmed = window.confirm('هل أنت متأكد من الموافقة على طلب كشف المصاريف؟');
if (!confirmed) return;
try {
setSubmittingId(id);
await portalAPI.approveManagedExpenseClaim(id);
await loadClaims(statusFilter);
} catch (error: any) {
alert(error?.response?.data?.message || 'تعذر تنفيذ الموافقة');
} finally {
setSubmittingId(null);
}
}
function openRejectModal(claim: ExpenseClaim) {
setSelectedClaim(claim);
setRejectReason('');
setRejectModalOpen(true);
}
async function handleRejectSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!selectedClaim) return;
if (!rejectReason.trim()) {
alert('سبب الرفض مطلوب');
return;
}
try {
setSubmittingId(selectedClaim.id);
await portalAPI.rejectManagedExpenseClaim(
selectedClaim.id,
rejectReason.trim()
);
setRejectModalOpen(false);
setSelectedClaim(null);
setRejectReason('');
await loadClaims(statusFilter);
} catch (error: any) {
alert(error?.response?.data?.message || 'تعذر تنفيذ الرفض');
} finally {
setSubmittingId(null);
}
}
return (
<div className="space-y-6" dir="rtl">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-2xl font-bold text-gray-900">
طلبات كشف المصاريف للقسم
</h1>
<p className="mt-1 text-sm text-gray-500">
يمكنك مراجعة طلبات كشف المصاريف والموافقة عليها أو رفضها
</p>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">الحالة:</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
>
<option value="PENDING">قيد المراجعة</option>
<option value="APPROVED">مقبول</option>
<option value="REJECTED">مرفوض</option>
<option value="all">الكل</option>
</select>
</div>
</div>
<div className="rounded-xl border bg-white shadow-sm">
<div className="border-b px-5 py-4">
<h2 className="text-lg font-semibold text-gray-900">الطلبات</h2>
</div>
{loading ? (
<div className="p-6 text-sm text-gray-500">جاري التحميل...</div>
) : claims.length === 0 ? (
<div className="p-6 text-sm text-gray-500">
لا توجد طلبات ضمن هذا الفلتر
</div>
) : (
<div className="divide-y">
{claims.map((claim) => {
const employeeName = claim.employee
? `${claim.employee.firstName} ${claim.employee.lastName}`
: '-';
const isBusy = submittingId === claim.id;
const isSelected = claim.id === claimId;
return (
<div
key={claim.id}
className={`p-5 ${
isSelected
? 'bg-yellow-50 ring-2 ring-yellow-300 rounded-lg'
: ''
}`}
>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-3 w-full">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-base font-bold text-gray-900">
{claim.claimNumber}
</span>
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${getStatusClasses(
claim.status
)}`}
>
{getStatusLabel(claim.status)}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2 text-sm text-gray-600">
<div>
<span className="font-medium text-gray-800">
الموظف:
</span>{' '}
{employeeName}
</div>
<div>
<span className="font-medium text-gray-800">
الرقم الوظيفي:
</span>{' '}
{claim.employee?.uniqueEmployeeId || '-'}
</div>
<div>
<span className="font-medium text-gray-800">
إجمالي المبلغ:
</span>{' '}
{claim.totalAmount ?? claim.amount ?? '-'}
</div>
<div>
<span className="font-medium text-gray-800">
تاريخ الإنشاء:
</span>{' '}
{formatDate(claim.createdAt)}
</div>
<div>
<span className="font-medium text-gray-800">
عدد البنود:
</span>{' '}
{Array.isArray(claim.items)
? claim.items.length
: claim.description
? 1
: 0}
</div>
<div>
<span className="font-medium text-gray-800">
آخر تحديث:
</span>{' '}
{formatDate(claim.updatedAt)}
</div>
</div>
{claim.description ? (
<div className="text-sm text-gray-700">
<span className="font-medium text-gray-800">
ملاحظات عامة:
</span>{' '}
{claim.description}
</div>
) : null}
{Array.isArray(claim.items) && claim.items.length > 0 ? (
<div className="mt-3 space-y-2">
<div className="text-sm font-medium text-gray-800">
البنود:
</div>
<div className="space-y-2">
{claim.items.map((item: any, idx: number) => (
<div
key={idx}
className="rounded-lg border bg-gray-50 p-3 text-sm text-gray-700"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<div>
<span className="font-medium">التاريخ:</span>{' '}
{formatDate(item.expenseDate)}
</div>
<div>
<span className="font-medium">المبلغ:</span>{' '}
{item.amount ?? '-'}
</div>
<div>
<span className="font-medium">اسم الجهة:</span>{' '}
{item.entityName || '-'}
</div>
<div>
<span className="font-medium">
المشروع / المناقصة:
</span>{' '}
{item.projectOrTender || '-'}
</div>
<div className="md:col-span-2">
<span className="font-medium">
الأوراق المثبتة:
</span>{' '}
{item.proofRef || '-'}
</div>
<div className="md:col-span-2">
<span className="font-medium">البيان:</span>{' '}
{item.description || '-'}
</div>
</div>
</div>
))}
</div>
</div>
) : (
<div className="mt-3 rounded-lg border bg-gray-50 p-3 text-sm text-gray-700">
<div>
<span className="font-medium">تاريخ المصروف:</span>{' '}
{formatDate(claim.expenseDate)}
</div>
<div>
<span className="font-medium">المبلغ:</span>{' '}
{claim.amount ?? '-'}
</div>
<div>
<span className="font-medium">
المشروع / المناقصة:
</span>{' '}
{claim.projectOrTender || '-'}
</div>
<div>
<span className="font-medium">البيان:</span>{' '}
{claim.description || '-'}
</div>
</div>
)}
{claim.status === 'REJECTED' && claim.rejectedReason ? (
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
<span className="font-medium">سبب الرفض:</span>{' '}
{claim.rejectedReason}
</div>
) : null}
{claim.status === 'PENDING' ? (
<div className="flex items-center gap-2 pt-2">
<button
onClick={() => handleApprove(claim.id)}
disabled={isBusy}
className="rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-60"
>
{isBusy ? 'جارٍ التنفيذ...' : 'موافقة'}
</button>
<button
onClick={() => openRejectModal(claim)}
disabled={isBusy}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-60"
>
رفض
</button>
</div>
) : null}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
<Modal
isOpen={rejectModalOpen}
onClose={() => {
setRejectModalOpen(false);
setSelectedClaim(null);
setRejectReason('');
}}
title="رفض طلب كشف المصاريف"
>
<form onSubmit={handleRejectSubmit} className="space-y-4">
<div className="text-sm text-gray-600">
{selectedClaim ? (
<>
سيتم رفض الطلب:{' '}
<span className="font-semibold">
{selectedClaim.claimNumber}
</span>
</>
) : null}
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
سبب الرفض
</label>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
className="min-h-[120px] w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-red-500"
placeholder="اكتب سبب الرفض"
required
/>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={() => {
setRejectModalOpen(false);
setSelectedClaim(null);
setRejectReason('');
}}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
إلغاء
</button>
<button
type="submit"
disabled={!selectedClaim || submittingId === selectedClaim?.id}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-60"
>
{selectedClaim && submittingId === selectedClaim.id
? 'جارٍ التنفيذ...'
: 'تأكيد الرفض'}
</button>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -5,7 +5,7 @@ import Link from 'next/link'
import { useAuth } from '@/contexts/AuthContext'
import { portalAPI, type PortalProfile } from '@/lib/api/portal'
import LoadingSpinner from '@/components/LoadingSpinner'
import { Banknote, Calendar, ShoppingCart, Clock, Plus, ArrowLeft, CheckCircle2 } from 'lucide-react'
import { Banknote, Calendar, ShoppingCart, Clock, Plus, ArrowLeft, CheckCircle2,FileText } from 'lucide-react'
import { toast } from 'react-hot-toast'
export default function PortalDashboardPage() {
@@ -25,7 +25,7 @@ export default function PortalDashboardPage() {
const { employee, stats } = data
const canViewDepartmentLeaveRequests = hasPermission('department_leave_requests', 'view')
const canViewDepartmentExpenseClaims = hasPermission('department_expense_claims', 'view')
const name = employee.firstNameAr && employee.lastNameAr
? `${employee.firstNameAr} ${employee.lastNameAr}`
: `${employee.firstName} ${employee.lastName}`
@@ -89,12 +89,27 @@ export default function PortalDashboardPage() {
</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-fuchsia-600 mt-1">{stats.pendingExpenseClaimsCount}</p>
</div>
<div className="bg-fuchsia-100 p-3 rounded-lg">
<FileText className="h-6 w-6 text-fuchsia-600" />
</div>
</div>
<Link href="/portal/expense-claims" className="mt-4 text-sm text-fuchsia-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}
{stats.pendingLeavesCount + stats.pendingPurchaseRequestsCount + stats.pendingExpenseClaimsCount}
</p>
</div>
<div className="bg-blue-100 p-3 rounded-lg">
@@ -104,6 +119,7 @@ export default function PortalDashboardPage() {
<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>
<Link href="/portal/expense-claims" className="text-sm text-blue-600 hover:underline">المصاريف</Link>
</div>
</div>
@@ -171,6 +187,11 @@ export default function PortalDashboardPage() {
</Link>
)}
<Link href="/portal/expense-claims" className="inline-flex items-center gap-2 px-4 py-2 bg-fuchsia-600 text-white rounded-lg hover:bg-fuchsia-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" />
طلب شراء

View File

@@ -16,6 +16,7 @@ export interface PortalProfile {
activeLoansCount: number
pendingLeavesCount: number
pendingPurchaseRequestsCount: number
pendingExpenseClaimsCount: number
leaveBalance: Array<{
leaveType: string
totalDays: number
@@ -98,6 +99,43 @@ export interface PurchaseRequest {
createdAt: string
}
export interface ExpenseClaim {
id: string;
employeeId: string;
claimNumber: string;
items?: ExpenseClaimItem[] | null;
totalAmount?: number | null;
expenseDate: string | null;
amount: number | null;
description: string | null;
projectOrTender: string | null;
status: 'PENDING' | 'APPROVED' | 'REJECTED' | string;
approvedBy?: string | null;
approvedAt?: string | null;
rejectedReason?: string | null;
createdAt: string;
updatedAt: string;
employee?: {
id: string;
firstName: string;
lastName: string;
uniqueEmployeeId?: string | null;
reportingToId?: string | null;
};
}
export interface ExpenseClaimItem {
expenseDate: string;
amount: number;
entityName?: string;
description: string;
projectOrTender?: string;
proofRef?: string;
}
export interface Attendance {
id: string
date: string
@@ -235,6 +273,43 @@ export const portalAPI = {
return response.data.data
},
getExpenseClaims: async (): Promise<ExpenseClaim[]> => {
const response = await api.get('/hr/portal/expense-claims')
return response.data.data || []
},
submitExpenseClaim: async (data: {
items: Array<{
expenseDate: string;
amount: number;
entityName?: string;
description: string;
projectOrTender?: string;
proofRef?: string;
}>;
description?: string;
}): Promise<ExpenseClaim> => {
const response = await api.post('/hr/portal/expense-claims', data);
return response.data.data;
},
getManagedExpenseClaims: async (status?: string): Promise<ExpenseClaim[]> => {
const q = new URLSearchParams()
if (status && status !== 'all') q.append('status', status)
const response = await api.get(`/hr/portal/managed-expense-claims?${q.toString()}`)
return response.data.data || []
},
approveManagedExpenseClaim: async (id: string): Promise<ExpenseClaim> => {
const response = await api.post(`/hr/portal/managed-expense-claims/${id}/approve`)
return response.data.data
},
rejectManagedExpenseClaim: async (id: string, rejectedReason: string): Promise<ExpenseClaim> => {
const response = await api.post(`/hr/portal/managed-expense-claims/${id}/reject`, { rejectedReason })
return response.data.data
},
getAttendance: async (month?: number, year?: number): Promise<Attendance[]> => {
const params = new URLSearchParams()
if (month) params.append('month', String(month))