edits for trenders attachments & claims

This commit is contained in:
Aya
2026-05-19 11:41:44 +03:00
parent 7732a40726
commit 12c4ca8334
19 changed files with 583 additions and 105 deletions

View File

@@ -4,6 +4,7 @@ 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 '-';
@@ -39,10 +40,15 @@ function getStatusClasses(status: string) {
}
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 [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);
@@ -51,11 +57,12 @@ export default function ManagedExpenseClaimsPage() {
const searchParams = useSearchParams();
const claimId = searchParams.get('claimId');
async function loadClaims(status = statusFilter) {
async function loadClaims(status = statusFilter, search = searchQuery) {
try {
setLoading(true);
const data = await portalAPI.getManagedExpenseClaims(
status === 'all' ? undefined : status
status === 'all' ? undefined : status,
search.trim() || undefined,
);
setClaims(data);
} catch (error: any) {
@@ -66,8 +73,13 @@ export default function ManagedExpenseClaimsPage() {
}
useEffect(() => {
loadClaims(statusFilter);
}, [statusFilter]);
// Debounce the search so we don't fire a request on every keystroke.
const handle = setTimeout(() => {
loadClaims(statusFilter, searchQuery);
}, 400);
return () => clearTimeout(handle);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statusFilter, searchQuery]);
async function openAttachment(attachment: any) {
try {
@@ -96,7 +108,7 @@ export default function ManagedExpenseClaimsPage() {
try {
setSubmittingId(id);
await portalAPI.approveManagedExpenseClaim(id, note.trim() || undefined);
await loadClaims(statusFilter);
await loadClaims(statusFilter, searchQuery);
} catch (error: any) {
alert(error?.response?.data?.message || 'تعذر تنفيذ الموافقة');
} finally {
@@ -129,7 +141,7 @@ export default function ManagedExpenseClaimsPage() {
setRejectModalOpen(false);
setSelectedClaim(null);
setRejectReason('');
await loadClaims(statusFilter);
await loadClaims(statusFilter, searchQuery);
} catch (error: any) {
alert(error?.response?.data?.message || 'تعذر تنفيذ الرفض');
} finally {
@@ -137,6 +149,37 @@ export default function ManagedExpenseClaimsPage() {
}
}
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">
@@ -149,18 +192,43 @@ export default function ManagedExpenseClaimsPage() {
</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 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>
</div>
@@ -365,6 +433,37 @@ export default function ManagedExpenseClaimsPage() {
</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