Files
zerp/frontend/src/app/portal/managed-expense-claims/page.tsx
2026-06-03 13:01:51 +03:00

575 lines
23 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';
import { useAuth } from '@/contexts/AuthContext';
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 { hasPermission } = useAuth();
const canMarkAsPaid = hasPermission('department_expense_claims', 'mark-as-paid');
const [claims, setClaims] = useState<ExpenseClaim[]>([]);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState('PENDING');
const [paidFilter, setPaidFilter] = useState<'all' | 'paid' | 'unpaid'>('all');
const [searchQuery, setSearchQuery] = useState('');
const [submittingId, setSubmittingId] = useState<string | null>(null);
const [payingId, setPayingId] = 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,
search = searchQuery,
paid: 'all' | 'paid' | 'unpaid' = paidFilter,
) {
try {
setLoading(true);
const data = await portalAPI.getManagedExpenseClaims(
status === 'all' ? undefined : status,
search.trim() || undefined,
paid,
);
setClaims(data);
} catch (error: any) {
alert(error?.response?.data?.message || 'تعذر تحميل طلبات كشف المصاريف');
} finally {
setLoading(false);
}
}
useEffect(() => {
// Debounce the search so we don't fire a request on every keystroke.
const handle = setTimeout(() => {
loadClaims(statusFilter, searchQuery, paidFilter);
}, 400);
return () => clearTimeout(handle);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statusFilter, searchQuery, paidFilter]);
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, searchQuery, paidFilter);
} 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, searchQuery, paidFilter);
} catch (error: any) {
alert(error?.response?.data?.message || 'تعذر تنفيذ الرفض');
} finally {
setSubmittingId(null);
}
}
async function handleTogglePaid(claim: ExpenseClaim, nextValue: boolean) {
if (!canMarkAsPaid) return;
if (payingId) return;
const previousValue = Boolean(claim.isPaid);
// Optimistic update for snappy UX.
setClaims((prev) =>
prev.map((c) => (c.id === claim.id ? { ...c, isPaid: nextValue } : c))
);
setPayingId(claim.id);
try {
const updated = await portalAPI.markExpenseClaimPaid(claim.id, nextValue);
// Sync local state with server response (in case server normalized anything).
setClaims((prev) =>
prev.map((c) =>
c.id === claim.id ? { ...c, isPaid: Boolean(updated?.isPaid) } : c
)
);
} catch (error: any) {
// Rollback on error.
setClaims((prev) =>
prev.map((c) => (c.id === claim.id ? { ...c, isPaid: previousValue } : c))
);
alert(error?.response?.data?.message || 'تعذر تحديث حالة القبض');
} finally {
setPayingId(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-3 flex-wrap">
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">بحث عن موظف:</label>
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="الاسم أو الرقم الوظيفي"
className="w-64 rounded-lg border border-gray-300 px-3 py-2 pe-8 text-sm"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
className="absolute inset-y-0 end-2 my-auto h-5 w-5 rounded-full text-gray-400 hover:text-gray-700"
aria-label="مسح البحث"
>
×
</button>
)}
</div>
</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 className="flex items-center gap-2">
<label className="text-sm text-gray-600">القبض:</label>
<select
value={paidFilter}
onChange={(e) => setPaidFilter(e.target.value as 'all' | 'paid' | 'unpaid')}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
>
<option value="all">الكل</option>
<option value="paid">مقبوض</option>
<option value="unpaid">غير مقبوض</option>
</select>
</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>
) : 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 === 'APPROVED' ? (
<div
className={`flex items-center gap-3 rounded-lg border px-3 py-2 text-sm ${
claim.isPaid
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
: 'border-gray-200 bg-gray-50 text-gray-700'
}`}
>
<input
type="checkbox"
id={`paid-${claim.id}`}
checked={Boolean(claim.isPaid)}
disabled={!canMarkAsPaid || payingId === claim.id}
onChange={(e) => handleTogglePaid(claim, e.target.checked)}
className="h-4 w-4 cursor-pointer accent-emerald-600 disabled:cursor-not-allowed"
/>
<label
htmlFor={`paid-${claim.id}`}
className={`font-medium ${canMarkAsPaid ? 'cursor-pointer' : 'cursor-default'}`}
>
{claim.isPaid ? 'مقبوض - تم الدفع' : 'غير مقبوض'}
</label>
{payingId === claim.id && (
<span className="text-xs text-gray-500">جارٍ الحفظ...</span>
)}
{!canMarkAsPaid && (
<span className="text-xs text-gray-500">(للعرض فقط)</span>
)}
</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>
);
}