edit for portal & tender
This commit is contained in:
@@ -75,12 +75,22 @@ export default function PortalExpenseClaimsPage() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<ExpenseClaimFormState>(initialForm);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [removeAttachmentIds, setRemoveAttachmentIds] = useState<string[]>([]);
|
||||
const [existingAttachments, setExistingAttachments] = useState<
|
||||
Array<{ id: string; originalName?: string; mimeType?: string }>
|
||||
>([]);
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [paidFilter, setPaidFilter] = useState<'all' | 'paid' | 'unpaid'>('all');
|
||||
const filteredClaims = useMemo(() => {
|
||||
if (statusFilter === 'all') return claims;
|
||||
return claims.filter((claim) => claim.status === statusFilter);
|
||||
}, [claims, statusFilter]);
|
||||
return claims.filter((claim) => {
|
||||
if (statusFilter !== 'all' && claim.status !== statusFilter) return false;
|
||||
if (paidFilter === 'paid' && !claim.isPaid) return false;
|
||||
if (paidFilter === 'unpaid' && claim.isPaid) return false;
|
||||
return true;
|
||||
});
|
||||
}, [claims, statusFilter, paidFilter]);
|
||||
|
||||
const totalAmount = useMemo(() => {
|
||||
return form.items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
||||
@@ -130,6 +140,51 @@ export default function PortalExpenseClaimsPage() {
|
||||
}));
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setForm(initialForm);
|
||||
setEditingId(null);
|
||||
setRemoveAttachmentIds([]);
|
||||
setExistingAttachments([]);
|
||||
};
|
||||
|
||||
const openEdit = (claim: ExpenseClaim) => {
|
||||
setEditingId(claim.id);
|
||||
const items = Array.isArray(claim.items) && claim.items.length > 0
|
||||
? claim.items.map((it: any) => ({
|
||||
expenseDate: it.expenseDate ? String(it.expenseDate).split('T')[0] : '',
|
||||
amount: String(it.amount ?? ''),
|
||||
entityName: it.entityName || '',
|
||||
description: it.description || '',
|
||||
projectOrTender: it.projectOrTender || '',
|
||||
proofRef: it.proofRef || '',
|
||||
}))
|
||||
: [emptyLine()];
|
||||
setForm({
|
||||
items,
|
||||
description: claim.description || '',
|
||||
attachments: [],
|
||||
});
|
||||
setExistingAttachments(
|
||||
(claim.attachments || []).map((a) => ({
|
||||
id: a.id,
|
||||
originalName: a.originalName,
|
||||
mimeType: a.mimeType,
|
||||
}))
|
||||
);
|
||||
setRemoveAttachmentIds([]);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('حذف كشف المصاريف؟')) return;
|
||||
try {
|
||||
await portalAPI.deleteExpenseClaim(id);
|
||||
setClaims((prev) => prev.filter((c) => c.id !== id));
|
||||
} catch (err: any) {
|
||||
alert(err?.response?.data?.message || 'فشل الحذف');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
async function openAttachment(attachment: any) {
|
||||
try {
|
||||
@@ -175,14 +230,22 @@ export default function PortalExpenseClaimsPage() {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
|
||||
await portalAPI.submitExpenseClaim({
|
||||
items,
|
||||
description: form.description.trim() || undefined,
|
||||
attachments: form.attachments,
|
||||
if (editingId) {
|
||||
await portalAPI.updateExpenseClaim(editingId, {
|
||||
items,
|
||||
description: form.description.trim() || undefined,
|
||||
attachments: form.attachments,
|
||||
removeAttachmentIds,
|
||||
});
|
||||
} else {
|
||||
await portalAPI.submitExpenseClaim({
|
||||
items,
|
||||
description: form.description.trim() || undefined,
|
||||
attachments: form.attachments,
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
setForm(initialForm);
|
||||
resetForm();
|
||||
setShowModal(false);
|
||||
await loadClaims();
|
||||
} catch (err: any) {
|
||||
@@ -206,14 +269,14 @@ export default function PortalExpenseClaimsPage() {
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
onClick={() => { resetForm(); 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="grid grid-cols-1 md:grid-cols-4 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">
|
||||
@@ -232,6 +295,16 @@ export default function PortalExpenseClaimsPage() {
|
||||
<option value="REJECTED">مرفوض</option>
|
||||
</select>
|
||||
|
||||
<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 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">
|
||||
@@ -279,6 +352,24 @@ export default function PortalExpenseClaimsPage() {
|
||||
|
||||
{getStatusLabel(claim.status)}
|
||||
</span>
|
||||
{claim.status === 'PENDING' && (
|
||||
<span className="inline-flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(claim)}
|
||||
className="text-xs text-teal-600 hover:underline"
|
||||
>
|
||||
تعديل
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(claim.id)}
|
||||
className="text-xs text-red-600 hover:underline"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{claim.status === 'APPROVED' && claim.approvalNote ? (
|
||||
<div className="rounded-lg bg-green-50 px-3 py-2 text-sm text-green-700">
|
||||
@@ -454,8 +545,8 @@ export default function PortalExpenseClaimsPage() {
|
||||
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
title="كشف مصاريف جديد"
|
||||
onClose={() => { setShowModal(false); resetForm(); }}
|
||||
title={editingId ? 'تعديل كشف المصاريف' : 'كشف مصاريف جديد'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
@@ -587,6 +678,36 @@ export default function PortalExpenseClaimsPage() {
|
||||
المرفقات
|
||||
</label>
|
||||
|
||||
{editingId && existingAttachments.length > 0 && (
|
||||
<div className="mb-2 space-y-1">
|
||||
<div className="text-xs text-gray-500">المرفقات الحالية:</div>
|
||||
{existingAttachments.map((a) => {
|
||||
const isRemoved = removeAttachmentIds.includes(a.id);
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
className={`flex items-center justify-between rounded-lg border px-3 py-2 text-sm ${
|
||||
isRemoved ? 'bg-red-50 border-red-200 text-red-600 line-through' : 'bg-gray-50 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{a.originalName || a.id}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRemoveAttachmentIds((prev) =>
|
||||
isRemoved ? prev.filter((id) => id !== a.id) : [...prev, a.id]
|
||||
);
|
||||
}}
|
||||
className="text-red-600 hover:underline shrink-0 ms-2"
|
||||
>
|
||||
{isRemoved ? 'استرجاع' : 'إزالة'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
@@ -647,7 +768,7 @@ export default function PortalExpenseClaimsPage() {
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
onClick={() => { setShowModal(false); resetForm(); }}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
إلغاء
|
||||
@@ -658,7 +779,7 @@ export default function PortalExpenseClaimsPage() {
|
||||
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 ? 'جارٍ الإرسال...' : 'إرسال الطلب'}
|
||||
{submitting ? 'جارٍ الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال الطلب')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -32,9 +32,10 @@ const toCompanyDateTime = (date: string, time: string) => {
|
||||
}
|
||||
|
||||
const formatCompanyTime = (value: string) => {
|
||||
return new Date(value).toLocaleTimeString('en-US', {
|
||||
return new Date(value).toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: COMPANY_TIME_ZONE,
|
||||
})
|
||||
}
|
||||
@@ -51,6 +52,7 @@ export default function PortalLeavePage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
const [form, setForm] = useState({
|
||||
leaveType: 'ANNUAL',
|
||||
@@ -74,6 +76,67 @@ export default function PortalLeavePage() {
|
||||
}
|
||||
|
||||
useEffect(() => load(), [])
|
||||
|
||||
const resetForm = () => {
|
||||
setForm({
|
||||
leaveType: 'ANNUAL',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
leaveDate: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
reason: '',
|
||||
})
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
const openEdit = (l: any) => {
|
||||
setEditingId(l.id)
|
||||
if (l.leaveType === 'HOURLY') {
|
||||
const start = new Date(l.startDate)
|
||||
const end = new Date(l.endDate)
|
||||
const dateStr = start.toLocaleDateString('en-CA', { timeZone: COMPANY_TIME_ZONE })
|
||||
const fmt = (d: Date) =>
|
||||
d.toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: COMPANY_TIME_ZONE,
|
||||
})
|
||||
setForm({
|
||||
leaveType: 'HOURLY',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
leaveDate: dateStr,
|
||||
startTime: fmt(start),
|
||||
endTime: fmt(end),
|
||||
reason: l.reason || '',
|
||||
})
|
||||
} else {
|
||||
setForm({
|
||||
leaveType: 'ANNUAL',
|
||||
startDate: String(l.startDate).split('T')[0],
|
||||
endDate: String(l.endDate).split('T')[0],
|
||||
leaveDate: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
reason: l.reason || '',
|
||||
})
|
||||
}
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('حذف طلب الإجازة؟')) return
|
||||
try {
|
||||
await portalAPI.deleteLeaveRequest(id)
|
||||
toast.success('تم حذف الطلب')
|
||||
load()
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || 'فشل الحذف')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -115,19 +178,15 @@ export default function PortalLeavePage() {
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
portalAPI.submitLeaveRequest(payload)
|
||||
const action = editingId
|
||||
? portalAPI.updateLeaveRequest(editingId, payload)
|
||||
: portalAPI.submitLeaveRequest(payload)
|
||||
|
||||
action
|
||||
.then(() => {
|
||||
setShowModal(false)
|
||||
setForm({
|
||||
leaveType: 'ANNUAL',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
leaveDate: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
reason: '',
|
||||
})
|
||||
toast.success('تم إرسال طلب الإجازة')
|
||||
resetForm()
|
||||
toast.success(editingId ? 'تم تعديل الطلب' : 'تم إرسال طلب الإجازة')
|
||||
load()
|
||||
})
|
||||
.catch((err: any) => {
|
||||
@@ -151,7 +210,7 @@ export default function PortalLeavePage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">إجازاتي</h1>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
onClick={() => { resetForm(); setShowModal(true) }}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -202,9 +261,29 @@ export default function PortalLeavePage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span className={`px-3 py-1 rounded-full text-xs ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-3 py-1 rounded-full text-xs ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
{l.status === 'PENDING' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(l)}
|
||||
className="text-xs text-teal-600 hover:underline"
|
||||
>
|
||||
تعديل
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(l.id)}
|
||||
className="text-xs text-red-600 hover:underline"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -213,7 +292,11 @@ export default function PortalLeavePage() {
|
||||
</div>
|
||||
|
||||
{/* الفورم */}
|
||||
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب إجازة جديد">
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => { setShowModal(false); resetForm() }}
|
||||
title={editingId ? 'تعديل طلب الإجازة' : 'طلب إجازة جديد'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
|
||||
{/* نوع الإجازة */}
|
||||
@@ -315,7 +398,7 @@ export default function PortalLeavePage() {
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
onClick={() => { setShowModal(false); resetForm() }}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
إلغاء
|
||||
@@ -326,7 +409,7 @@ export default function PortalLeavePage() {
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
|
||||
{submitting ? 'جاري الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال الطلب')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function PortalLoansPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' })
|
||||
|
||||
useEffect(() => {
|
||||
@@ -29,6 +30,33 @@ export default function PortalLoansPage() {
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const resetForm = () => {
|
||||
setForm({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' })
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
const openEdit = (loan: Loan) => {
|
||||
setEditingId(loan.id)
|
||||
setForm({
|
||||
type: loan.type,
|
||||
amount: String(loan.amount ?? ''),
|
||||
installments: String(loan.installments ?? '1'),
|
||||
reason: loan.reason || '',
|
||||
})
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('حذف طلب القرض؟')) return
|
||||
try {
|
||||
await portalAPI.deleteLoanRequest(id)
|
||||
toast.success('تم الحذف')
|
||||
setLoans((prev) => prev.filter((l) => l.id !== id))
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || 'فشل الحذف')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const amount = parseFloat(form.amount)
|
||||
@@ -44,19 +72,27 @@ export default function PortalLoansPage() {
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
portalAPI.submitLoanRequest({
|
||||
const payload = {
|
||||
type: form.type,
|
||||
amount,
|
||||
installments: parseInt(form.installments) || 1,
|
||||
reason: form.reason.trim(),
|
||||
})
|
||||
}
|
||||
const action = editingId
|
||||
? portalAPI.updateLoanRequest(editingId, payload)
|
||||
: portalAPI.submitLoanRequest(payload)
|
||||
action
|
||||
.then((loan) => {
|
||||
setLoans((prev) => [loan, ...prev])
|
||||
if (editingId) {
|
||||
setLoans((prev) => prev.map((l) => (l.id === editingId ? loan : l)))
|
||||
} else {
|
||||
setLoans((prev) => [loan, ...prev])
|
||||
}
|
||||
setShowModal(false)
|
||||
setForm({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' })
|
||||
toast.success('تم إرسال طلب القرض')
|
||||
resetForm()
|
||||
toast.success(editingId ? 'تم تعديل الطلب' : 'تم إرسال طلب القرض')
|
||||
})
|
||||
.catch(() => toast.error('فشل إرسال الطلب'))
|
||||
.catch((err: any) => toast.error(err?.response?.data?.message || 'فشل إرسال الطلب'))
|
||||
.finally(() => setSubmitting(false))
|
||||
}
|
||||
|
||||
@@ -67,7 +103,7 @@ export default function PortalLoansPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">قروضي</h1>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
onClick={() => { resetForm(); setShowModal(true) }}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -113,9 +149,17 @@ export default function PortalLoansPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
{loan.status === 'PENDING_HR' && (
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => openEdit(loan)} className="text-xs text-teal-600 hover:underline">تعديل</button>
|
||||
<button type="button" onClick={() => handleDelete(loan.id)} className="text-xs text-red-600 hover:underline">حذف</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{loan.rejectedReason && (
|
||||
<p className="mt-2 text-sm text-red-600">سبب الرفض: {loan.rejectedReason}</p>
|
||||
@@ -126,7 +170,11 @@ export default function PortalLoansPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب قرض جديد">
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => { setShowModal(false); resetForm() }}
|
||||
title={editingId ? 'تعديل طلب القرض' : 'طلب قرض جديد'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">نوع القرض</label>
|
||||
@@ -177,7 +225,7 @@ export default function PortalLoansPage() {
|
||||
إلغاء
|
||||
</button>
|
||||
<button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50">
|
||||
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
|
||||
{submitting ? 'جاري الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال الطلب')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -46,6 +46,7 @@ export default function ManagedExpenseClaimsPage() {
|
||||
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);
|
||||
@@ -57,12 +58,17 @@ export default function ManagedExpenseClaimsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const claimId = searchParams.get('claimId');
|
||||
|
||||
async function loadClaims(status = statusFilter, search = searchQuery) {
|
||||
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) {
|
||||
@@ -75,11 +81,11 @@ export default function ManagedExpenseClaimsPage() {
|
||||
useEffect(() => {
|
||||
// Debounce the search so we don't fire a request on every keystroke.
|
||||
const handle = setTimeout(() => {
|
||||
loadClaims(statusFilter, searchQuery);
|
||||
loadClaims(statusFilter, searchQuery, paidFilter);
|
||||
}, 400);
|
||||
return () => clearTimeout(handle);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [statusFilter, searchQuery]);
|
||||
}, [statusFilter, searchQuery, paidFilter]);
|
||||
|
||||
async function openAttachment(attachment: any) {
|
||||
try {
|
||||
@@ -108,7 +114,7 @@ export default function ManagedExpenseClaimsPage() {
|
||||
try {
|
||||
setSubmittingId(id);
|
||||
await portalAPI.approveManagedExpenseClaim(id, note.trim() || undefined);
|
||||
await loadClaims(statusFilter, searchQuery);
|
||||
await loadClaims(statusFilter, searchQuery, paidFilter);
|
||||
} catch (error: any) {
|
||||
alert(error?.response?.data?.message || 'تعذر تنفيذ الموافقة');
|
||||
} finally {
|
||||
@@ -141,7 +147,7 @@ export default function ManagedExpenseClaimsPage() {
|
||||
setRejectModalOpen(false);
|
||||
setSelectedClaim(null);
|
||||
setRejectReason('');
|
||||
await loadClaims(statusFilter, searchQuery);
|
||||
await loadClaims(statusFilter, searchQuery, paidFilter);
|
||||
} catch (error: any) {
|
||||
alert(error?.response?.data?.message || 'تعذر تنفيذ الرفض');
|
||||
} finally {
|
||||
@@ -229,6 +235,19 @@ export default function ManagedExpenseClaimsPage() {
|
||||
<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>
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function PortalOvertimePage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
const [form, setForm] = useState({
|
||||
date: '',
|
||||
@@ -41,6 +42,32 @@ export default function PortalOvertimePage() {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const resetForm = () => {
|
||||
setForm({ date: '', hours: '', reason: '' })
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
const openEdit = (item: PortalOvertimeRequest) => {
|
||||
setEditingId(item.attendanceId || item.id)
|
||||
setForm({
|
||||
date: String(item.date).split('T')[0],
|
||||
hours: String(item.hours ?? ''),
|
||||
reason: item.reason || '',
|
||||
})
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('حذف طلب الساعات الإضافية؟')) return
|
||||
try {
|
||||
await portalAPI.deleteOvertimeRequest(id)
|
||||
toast.success('تم الحذف')
|
||||
loadData()
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || 'فشل الحذف')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -63,14 +90,22 @@ export default function PortalOvertimePage() {
|
||||
|
||||
try {
|
||||
setSubmitting(true)
|
||||
await portalAPI.submitOvertimeRequest({
|
||||
date: form.date,
|
||||
hours,
|
||||
reason: form.reason.trim(),
|
||||
})
|
||||
toast.success('تم إرسال الطلب')
|
||||
if (editingId) {
|
||||
await portalAPI.updateOvertimeRequest(editingId, {
|
||||
hours,
|
||||
reason: form.reason.trim(),
|
||||
})
|
||||
toast.success('تم تعديل الطلب')
|
||||
} else {
|
||||
await portalAPI.submitOvertimeRequest({
|
||||
date: form.date,
|
||||
hours,
|
||||
reason: form.reason.trim(),
|
||||
})
|
||||
toast.success('تم إرسال الطلب')
|
||||
}
|
||||
setOpen(false)
|
||||
setForm({ date: '', hours: '', reason: '' })
|
||||
resetForm()
|
||||
loadData()
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.message || 'فشل إرسال الطلب')
|
||||
@@ -90,7 +125,7 @@ export default function PortalOvertimePage() {
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
onClick={() => { resetForm(); setOpen(true) }}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -121,9 +156,17 @@ export default function PortalOvertimePage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${meta.color}`}>
|
||||
{meta.label}
|
||||
</span>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${meta.color}`}>
|
||||
{meta.label}
|
||||
</span>
|
||||
{item.status === 'PENDING' && (
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => openEdit(item)} className="text-xs text-teal-600 hover:underline">تعديل</button>
|
||||
<button type="button" onClick={() => handleDelete(item.attendanceId || item.id)} className="text-xs text-red-600 hover:underline">حذف</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -131,7 +174,11 @@ export default function PortalOvertimePage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal isOpen={open} onClose={() => setOpen(false)} title="طلب ساعات إضافية">
|
||||
<Modal
|
||||
isOpen={open}
|
||||
onClose={() => { setOpen(false); resetForm() }}
|
||||
title={editingId ? 'تعديل طلب الساعات الإضافية' : 'طلب ساعات إضافية'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block mb-1 text-sm font-medium text-gray-700">التاريخ</label>
|
||||
@@ -140,6 +187,7 @@ export default function PortalOvertimePage() {
|
||||
value={form.date}
|
||||
onChange={(e) => setForm((p) => ({ ...p, date: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
disabled={!!editingId}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -171,7 +219,7 @@ export default function PortalOvertimePage() {
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
onClick={() => { setOpen(false); resetForm() }}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
إلغاء
|
||||
@@ -181,7 +229,7 @@ export default function PortalOvertimePage() {
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'جارٍ الإرسال...' : 'إرسال'}
|
||||
{submitting ? 'جارٍ الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function PortalPurchaseRequestsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState({
|
||||
items: [{ description: '', quantity: 1, estimatedPrice: '' }],
|
||||
reason: '',
|
||||
@@ -41,6 +42,35 @@ export default function PortalPurchaseRequestsPage() {
|
||||
items: p.items.map((it, idx) => (idx === i ? { ...it, [key]: value } : it)),
|
||||
}))
|
||||
|
||||
const resetForm = () => {
|
||||
setForm({ items: [{ description: '', quantity: 1, estimatedPrice: '' }], reason: '', priority: 'NORMAL' })
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
const openEdit = (pr: PurchaseRequest) => {
|
||||
setEditingId(pr.id)
|
||||
const items = Array.isArray(pr.items) && pr.items.length > 0
|
||||
? pr.items.map((it: any) => ({
|
||||
description: String(it.description || ''),
|
||||
quantity: Number(it.quantity || 1),
|
||||
estimatedPrice: String(it.estimatedPrice ?? ''),
|
||||
}))
|
||||
: [{ description: '', quantity: 1, estimatedPrice: '' }]
|
||||
setForm({ items, reason: pr.reason || '', priority: pr.priority || 'NORMAL' })
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('حذف طلب الشراء؟')) return
|
||||
try {
|
||||
await portalAPI.deletePurchaseRequest(id)
|
||||
toast.success('تم الحذف')
|
||||
setRequests((prev) => prev.filter((r) => r.id !== id))
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || 'فشل الحذف')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const items = form.items
|
||||
@@ -55,18 +85,22 @@ export default function PortalPurchaseRequestsPage() {
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
portalAPI.submitPurchaseRequest({
|
||||
items,
|
||||
reason: form.reason || undefined,
|
||||
priority: form.priority,
|
||||
})
|
||||
const payload = { items, reason: form.reason || undefined, priority: form.priority }
|
||||
const action = editingId
|
||||
? portalAPI.updatePurchaseRequest(editingId, payload)
|
||||
: portalAPI.submitPurchaseRequest(payload)
|
||||
action
|
||||
.then((pr) => {
|
||||
setRequests((prev) => [pr, ...prev])
|
||||
if (editingId) {
|
||||
setRequests((prev) => prev.map((r) => (r.id === editingId ? pr : r)))
|
||||
} else {
|
||||
setRequests((prev) => [pr, ...prev])
|
||||
}
|
||||
setShowModal(false)
|
||||
setForm({ items: [{ description: '', quantity: 1, estimatedPrice: '' }], reason: '', priority: 'NORMAL' })
|
||||
toast.success('تم إرسال طلب الشراء')
|
||||
resetForm()
|
||||
toast.success(editingId ? 'تم تعديل الطلب' : 'تم إرسال طلب الشراء')
|
||||
})
|
||||
.catch(() => toast.error('فشل إرسال الطلب'))
|
||||
.catch((err: any) => toast.error(err?.response?.data?.message || 'فشل إرسال الطلب'))
|
||||
.finally(() => setSubmitting(false))
|
||||
}
|
||||
|
||||
@@ -77,7 +111,7 @@ export default function PortalPurchaseRequestsPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">طلبات الشراء</h1>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
onClick={() => { resetForm(); setShowModal(true) }}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -121,9 +155,17 @@ export default function PortalPurchaseRequestsPage() {
|
||||
<p className="mt-2 text-sm text-red-600">سبب الرفض: {pr.rejectedReason}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
{pr.status === 'PENDING' && (
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => openEdit(pr)} className="text-xs text-teal-600 hover:underline">تعديل</button>
|
||||
<button type="button" onClick={() => handleDelete(pr.id)} className="text-xs text-red-600 hover:underline">حذف</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -131,7 +173,11 @@ export default function PortalPurchaseRequestsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب شراء جديد">
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => { setShowModal(false); resetForm() }}
|
||||
title={editingId ? 'تعديل طلب الشراء' : 'طلب شراء جديد'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
@@ -200,7 +246,7 @@ export default function PortalPurchaseRequestsPage() {
|
||||
إلغاء
|
||||
</button>
|
||||
<button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50">
|
||||
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
|
||||
{submitting ? 'جاري الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال الطلب')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -87,10 +87,13 @@ function TenderDetailContent() {
|
||||
const [completeNotes, setCompleteNotes] = useState('')
|
||||
const [directiveTypeValues, setDirectiveTypeValues] = useState<string[]>([])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const directiveFileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [uploadingDirectiveId, setUploadingDirectiveId] = useState<string | null>(null)
|
||||
const [directiveIdForUpload, setDirectiveIdForUpload] = useState<string | null>(null)
|
||||
const [uploadingCategory, setUploadingCategory] = useState<string | null>(null)
|
||||
const termsInputRef = useRef<HTMLInputElement>(null)
|
||||
const costInputRef = useRef<HTMLInputElement>(null)
|
||||
const offersInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const fetchTender = async () => {
|
||||
try {
|
||||
@@ -213,11 +216,15 @@ function TenderDetailContent() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleTenderFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleTenderFileUpload = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
category?: string,
|
||||
) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (!files.length) return
|
||||
|
||||
setSubmitting(true)
|
||||
if (category) setUploadingCategory(category)
|
||||
else setSubmitting(true)
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
@@ -225,7 +232,7 @@ function TenderDetailContent() {
|
||||
// Upload files sequentially so a failure of one file doesn't break the rest.
|
||||
for (const file of files) {
|
||||
try {
|
||||
await tendersAPI.uploadTenderAttachment(tenderId, file)
|
||||
await tendersAPI.uploadTenderAttachment(tenderId, file, category)
|
||||
successCount++
|
||||
} catch (err: any) {
|
||||
failCount++
|
||||
@@ -244,6 +251,7 @@ function TenderDetailContent() {
|
||||
if (successCount > 0) fetchTender()
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
setUploadingCategory(null)
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
@@ -525,66 +533,102 @@ function TenderDetailContent() {
|
||||
|
||||
{activeTab === 'attachments' && (
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleTenderFileUpload}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={submitting}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
{t('tenders.uploadFile')}
|
||||
</button>
|
||||
</div>
|
||||
{(() => {
|
||||
const all = (tender.attachments || []) as any[]
|
||||
const sections: Array<{
|
||||
key: string
|
||||
label: string
|
||||
category: string
|
||||
ref: React.RefObject<HTMLInputElement>
|
||||
}> = [
|
||||
{ key: 'terms', label: 'دفتر الشروط', category: 'TERMS_BOOKLET', ref: termsInputRef },
|
||||
{ key: 'cost', label: 'cost sheet ', category: 'COST_SHEET', ref: costInputRef },
|
||||
{ key: 'offers', label: 'proposal', category: 'OFFERS', ref: offersInputRef },
|
||||
]
|
||||
|
||||
{!tender.attachments?.length ? (
|
||||
<p className="text-gray-500">{t('common.noData')}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{tender.attachments.map((a: any) => (
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex items-center justify-between border rounded px-3 py-2"
|
||||
>
|
||||
<a
|
||||
href={`${process.env.NEXT_PUBLIC_API_URL}/tenders/attachments/${a.id}/view`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-indigo-600 hover:underline flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
{getDisplayFileName(a)}
|
||||
</a>
|
||||
// Legacy attachments without a recognized category live under
|
||||
// the dafter section by default so nothing gets hidden.
|
||||
const knownCategories = new Set(sections.map((s) => s.category))
|
||||
const inSection = (a: any, category: string) =>
|
||||
a.category === category ||
|
||||
(category === 'TERMS_BOOKLET' && (!a.category || !knownCategories.has(a.category)))
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm('حذف الملف؟')) return
|
||||
try {
|
||||
await tendersAPI.deleteAttachment(a.id)
|
||||
toast.success('تم الحذف')
|
||||
fetchTender()
|
||||
} catch {
|
||||
toast.error('فشل الحذف')
|
||||
}
|
||||
}}
|
||||
className="text-red-600 text-sm hover:underline"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{sections.map((section) => {
|
||||
const items = all.filter((a) => inSection(a, section.category))
|
||||
const isUploading = uploadingCategory === section.category
|
||||
return (
|
||||
<div key={section.key} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-base font-semibold text-gray-900">{section.label}</h3>
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
ref={section.ref}
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => handleTenderFileUpload(e, section.category)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => section.ref.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 text-sm"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
{t('tenders.uploadFile')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">{t('common.noData')}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{items.map((a: any) => (
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex items-center justify-between border rounded px-3 py-2"
|
||||
>
|
||||
<a
|
||||
href={`${process.env.NEXT_PUBLIC_API_URL}/tenders/attachments/${a.id}/view`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-indigo-600 hover:underline flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
{getDisplayFileName(a)}
|
||||
</a>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm('حذف الملف؟')) return
|
||||
try {
|
||||
await tendersAPI.deleteAttachment(a.id)
|
||||
toast.success('تم الحذف')
|
||||
fetchTender()
|
||||
} catch {
|
||||
toast.error('فشل الحذف')
|
||||
}
|
||||
}}
|
||||
className="text-red-600 text-sm hover:underline"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ const ANNOUNCEMENT_LABELS: Record<string, string> = {
|
||||
|
||||
const getInitialFormData = (): CreateTenderData => ({
|
||||
tenderNumber: '',
|
||||
issueNumber: '',
|
||||
issuingBodyName: '',
|
||||
title: '',
|
||||
termsValue: 0,
|
||||
@@ -114,6 +115,7 @@ function TendersContent() {
|
||||
|
||||
const fillFormFromTender = (tender: Tender): CreateTenderData => ({
|
||||
tenderNumber: tender.tenderNumber || '',
|
||||
issueNumber: tender.issueNumber || '',
|
||||
issuingBodyName: tender.issuingBodyName || '',
|
||||
title: tender.title || '',
|
||||
termsValue: Number(tender.termsValue || 0),
|
||||
@@ -316,6 +318,19 @@ function TendersContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
رقم العدد
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.issueNumber || ''}
|
||||
onChange={(e) => setFormData({ ...formData, issueNumber: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
placeholder="رقم العدد (اختياري)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('tenders.titleLabel')} *
|
||||
@@ -684,6 +699,9 @@ function TendersContent() {
|
||||
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
{t('tenders.tenderNumber') || 'Number'}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
رقم العدد
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
{t('tenders.title') || 'Title'}
|
||||
</th>
|
||||
@@ -708,6 +726,9 @@ function TendersContent() {
|
||||
<td className="px-6 py-4 text-sm font-semibold text-gray-900 text-right align-middle">
|
||||
{tender.tenderNumber}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-700 text-right align-middle">
|
||||
{tender.issueNumber || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-900 text-right align-middle">
|
||||
{tender.title}
|
||||
</td>
|
||||
|
||||
@@ -234,6 +234,18 @@ export const portalAPI = {
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
updateLoanRequest: async (
|
||||
id: string,
|
||||
data: { type?: string; amount?: number; installments?: number; reason?: string }
|
||||
): Promise<Loan> => {
|
||||
const response = await api.put(`/hr/portal/loans/${id}`, data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
deleteLoanRequest: async (id: string): Promise<void> => {
|
||||
await api.delete(`/hr/portal/loans/${id}`)
|
||||
},
|
||||
|
||||
getOvertimeRequests: async (): Promise<PortalOvertimeRequest[]> => {
|
||||
const response = await api.get('/hr/portal/overtime-requests')
|
||||
return response.data.data || []
|
||||
@@ -248,6 +260,18 @@ export const portalAPI = {
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
updateOvertimeRequest: async (
|
||||
attendanceId: string,
|
||||
data: { hours?: number; reason?: string }
|
||||
): Promise<PortalOvertimeRequest> => {
|
||||
const response = await api.put(`/hr/portal/overtime-requests/${attendanceId}`, data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
deleteOvertimeRequest: async (attendanceId: string): Promise<void> => {
|
||||
await api.delete(`/hr/portal/overtime-requests/${attendanceId}`)
|
||||
},
|
||||
|
||||
getManagedOvertimeRequests: async (): Promise<PortalOvertimeRequest[]> => {
|
||||
const response = await api.get('/hr/portal/managed-overtime-requests')
|
||||
return response.data.data || []
|
||||
@@ -292,6 +316,26 @@ export const portalAPI = {
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
updateLeaveRequest: async (
|
||||
id: string,
|
||||
data: {
|
||||
leaveType?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
leaveDate?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
reason?: string
|
||||
}
|
||||
): Promise<Leave> => {
|
||||
const response = await api.put(`/hr/portal/leaves/${id}`, data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
deleteLeaveRequest: async (id: string): Promise<void> => {
|
||||
await api.delete(`/hr/portal/leaves/${id}`)
|
||||
},
|
||||
|
||||
getPurchaseRequests: async (): Promise<PurchaseRequest[]> => {
|
||||
const response = await api.get('/hr/portal/purchase-requests')
|
||||
return response.data.data || []
|
||||
@@ -302,6 +346,22 @@ export const portalAPI = {
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
updatePurchaseRequest: async (
|
||||
id: string,
|
||||
data: {
|
||||
items?: Array<{ description: string; quantity?: number; estimatedPrice?: number }>
|
||||
reason?: string
|
||||
priority?: string
|
||||
}
|
||||
): Promise<PurchaseRequest> => {
|
||||
const response = await api.put(`/hr/portal/purchase-requests/${id}`, data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
deletePurchaseRequest: async (id: string): Promise<void> => {
|
||||
await api.delete(`/hr/portal/purchase-requests/${id}`)
|
||||
},
|
||||
|
||||
getExpenseClaims: async (): Promise<ExpenseClaim[]> => {
|
||||
const response = await api.get('/hr/portal/expense-claims')
|
||||
return response.data.data || []
|
||||
@@ -340,10 +400,50 @@ export const portalAPI = {
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getManagedExpenseClaims: async (status?: string, search?: string): Promise<ExpenseClaim[]> => {
|
||||
updateExpenseClaim: async (
|
||||
id: string,
|
||||
data: {
|
||||
items: Array<{
|
||||
expenseDate: string;
|
||||
amount: number;
|
||||
entityName?: string;
|
||||
description: string;
|
||||
projectOrTender?: string;
|
||||
proofRef?: string;
|
||||
}>;
|
||||
description?: string;
|
||||
attachments?: File[];
|
||||
removeAttachmentIds?: string[];
|
||||
}
|
||||
): Promise<ExpenseClaim> => {
|
||||
const formData = new FormData();
|
||||
formData.append('items', JSON.stringify(data.items));
|
||||
if (data.description) formData.append('description', data.description);
|
||||
if (data.removeAttachmentIds && data.removeAttachmentIds.length > 0) {
|
||||
formData.append('removeAttachmentIds', JSON.stringify(data.removeAttachmentIds));
|
||||
}
|
||||
if (data.attachments && data.attachments.length > 0) {
|
||||
for (const file of data.attachments) formData.append('attachments', file);
|
||||
}
|
||||
const response = await api.put(`/hr/portal/expense-claims/${id}`, formData, {
|
||||
headers: { 'Content-Type': undefined as any },
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
deleteExpenseClaim: async (id: string): Promise<void> => {
|
||||
await api.delete(`/hr/portal/expense-claims/${id}`);
|
||||
},
|
||||
|
||||
getManagedExpenseClaims: async (
|
||||
status?: string,
|
||||
search?: string,
|
||||
paid?: 'all' | 'paid' | 'unpaid',
|
||||
): Promise<ExpenseClaim[]> => {
|
||||
const q = new URLSearchParams()
|
||||
if (status && status !== 'all') q.append('status', status)
|
||||
if (search && search.trim()) q.append('search', search.trim())
|
||||
if (paid && paid !== 'all') q.append('paid', paid)
|
||||
const response = await api.get(`/hr/portal/managed-expense-claims?${q.toString()}`)
|
||||
return response.data.data || []
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { api } from '../api'
|
||||
export interface Tender {
|
||||
id: string
|
||||
tenderNumber: string
|
||||
issueNumber?: string | null
|
||||
issuingBodyName: string
|
||||
title: string
|
||||
|
||||
@@ -57,6 +58,7 @@ export interface TenderDirective {
|
||||
|
||||
export interface CreateTenderData {
|
||||
tenderNumber: string
|
||||
issueNumber?: string
|
||||
issuingBodyName: string
|
||||
title: string
|
||||
|
||||
|
||||
Reference in New Issue
Block a user