Files
zerp/frontend/src/app/portal/managed-expense-claims/page.tsx
2026-05-03 10:30:03 +03:00

457 lines
18 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 { 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>
);
}