457 lines
18 KiB
TypeScript
457 lines
18 KiB
TypeScript
'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 openAttachment(attachment: any) {
|
||
try {
|
||
const blob = await portalAPI.viewExpenseClaimAttachment(attachment.id);
|
||
const blobUrl = window.URL.createObjectURL(
|
||
new Blob([blob], { type: attachment.mimeType })
|
||
);
|
||
|
||
window.open(blobUrl, '_blank');
|
||
|
||
setTimeout(() => {
|
||
window.URL.revokeObjectURL(blobUrl);
|
||
}, 10000);
|
||
} catch (error) {
|
||
alert('تعذر فتح المرفق');
|
||
}
|
||
}
|
||
|
||
async function handleApprove(id: string) {
|
||
const note = window.prompt(
|
||
'ملاحظة مع الموافقة (اتركها فارغة إذا لا توجد):',
|
||
'',
|
||
);
|
||
if (note === null) return;
|
||
|
||
try {
|
||
setSubmittingId(id);
|
||
await portalAPI.approveManagedExpenseClaim(id, note.trim() || undefined);
|
||
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.attachments && claim.attachments.length > 0 ? (
|
||
<div className="mt-3 space-y-2">
|
||
<div className="text-sm font-medium text-gray-800">المرفقات:</div>
|
||
|
||
<div className="space-y-1">
|
||
{claim.attachments.map((attachment) => (
|
||
<a
|
||
key={attachment.id}
|
||
href={`${process.env.NEXT_PUBLIC_API_URL}/hr/portal/expense-claims/attachments/${attachment.id}/view`}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="block rounded-lg border bg-white px-3 py-2 text-sm text-blue-600 hover:underline"
|
||
>
|
||
{attachment.originalName}
|
||
</a>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{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 === 'APPROVED' && claim.approvalNote ? (
|
||
<div className="rounded-lg bg-green-50 px-3 py-2 text-sm text-green-700">
|
||
<span className="font-medium">ملاحظة المعتمِد:</span>{' '}
|
||
{claim.approvalNote}
|
||
</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>
|
||
);
|
||
} |