edit for portal & tender

This commit is contained in:
Aya
2026-06-03 13:01:51 +03:00
parent 61ca570e7a
commit 96386887fb
17 changed files with 1280 additions and 147 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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 || []
},

View File

@@ -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