update expense-claims
This commit is contained in:
@@ -17,6 +17,7 @@ type ExpenseClaimLine = {
|
||||
type ExpenseClaimFormState = {
|
||||
items: ExpenseClaimLine[];
|
||||
description: string;
|
||||
attachment: File | null;
|
||||
};
|
||||
|
||||
const emptyLine = (): ExpenseClaimLine => ({
|
||||
@@ -31,6 +32,7 @@ const emptyLine = (): ExpenseClaimLine => ({
|
||||
const initialForm: ExpenseClaimFormState = {
|
||||
items: [emptyLine()],
|
||||
description: '',
|
||||
attachment: null,
|
||||
};
|
||||
|
||||
function getStatusLabel(status: string) {
|
||||
@@ -74,10 +76,11 @@ export default function PortalExpenseClaimsPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<ExpenseClaimFormState>(initialForm);
|
||||
|
||||
const pendingCount = useMemo(
|
||||
() => claims.filter((c) => c.status === 'PENDING').length,
|
||||
[claims]
|
||||
);
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const filteredClaims = useMemo(() => {
|
||||
if (statusFilter === 'all') return claims;
|
||||
return claims.filter((claim) => claim.status === statusFilter);
|
||||
}, [claims, statusFilter]);
|
||||
|
||||
const totalAmount = useMemo(() => {
|
||||
return form.items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
||||
@@ -127,6 +130,24 @@ export default function PortalExpenseClaimsPage() {
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
async function openAttachment(attachment: any) {
|
||||
try {
|
||||
const blob = await portalAPI.viewExpenseClaimAttachment(attachment.id);
|
||||
const blobUrl = window.URL.createObjectURL(
|
||||
new Blob([blob], { type: attachment.mimeType })
|
||||
);
|
||||
|
||||
window.open(blobUrl, '_blank');
|
||||
|
||||
setTimeout(() => {
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
}, 10000);
|
||||
} catch (error) {
|
||||
alert('تعذر فتح المرفق');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -157,6 +178,8 @@ export default function PortalExpenseClaimsPage() {
|
||||
await portalAPI.submitExpenseClaim({
|
||||
items,
|
||||
description: form.description.trim() || undefined,
|
||||
attachment: form.attachment,
|
||||
|
||||
});
|
||||
|
||||
setForm(initialForm);
|
||||
@@ -198,12 +221,16 @@ export default function PortalExpenseClaimsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-yellow-600">
|
||||
{pendingCount}
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="all">الكل</option>
|
||||
<option value="PENDING">قيد المراجعة</option>
|
||||
<option value="APPROVED">مقبول</option>
|
||||
<option value="REJECTED">مرفوض</option>
|
||||
</select>
|
||||
|
||||
<div className="rounded-xl border bg-white p-5 shadow-sm">
|
||||
<div className="text-sm text-gray-500">آخر تحديث</div>
|
||||
@@ -222,14 +249,14 @@ export default function PortalExpenseClaimsPage() {
|
||||
<div className="p-6 text-sm text-gray-500">جاري التحميل...</div>
|
||||
) : error ? (
|
||||
<div className="p-6 text-sm text-red-600">{error}</div>
|
||||
) : claims.length === 0 ? (
|
||||
<div className="p-6 text-sm text-gray-500">
|
||||
) : filteredClaims.length === 0 ? (
|
||||
<div className="p-6 text-sm text-gray-500">
|
||||
لا توجد طلبات كشف مصاريف حالياً
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{claims.map((claim) => {
|
||||
const isSelected = claim.id === claimId;
|
||||
{filteredClaims.map((claim) => {
|
||||
const isSelected = claim.id === claimId;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -249,9 +276,15 @@ export default function PortalExpenseClaimsPage() {
|
||||
claim.status
|
||||
)}`}
|
||||
>
|
||||
|
||||
{getStatusLabel(claim.status)}
|
||||
</span>
|
||||
</div>
|
||||
{claim.status === 'APPROVED' && claim.approvalNote ? (
|
||||
<div className="rounded-lg bg-green-50 px-3 py-2 text-sm text-green-700">
|
||||
<span className="font-medium">ملاحظة المعتمِد:</span> {claim.approvalNote}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2 text-sm text-gray-600">
|
||||
<div>
|
||||
@@ -333,6 +366,24 @@ export default function PortalExpenseClaimsPage() {
|
||||
</span>{' '}
|
||||
{item.proofRef || '-'}
|
||||
</div>
|
||||
{claim.attachments && claim.attachments.length > 0 ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="text-sm font-medium text-gray-800">المرفقات:</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{claim.attachments.map((attachment) => (
|
||||
<button
|
||||
key={attachment.id}
|
||||
type="button"
|
||||
onClick={() => openAttachment(attachment)}
|
||||
className="block w-full text-right rounded-lg border bg-white px-3 py-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
{attachment.originalName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="md:col-span-2">
|
||||
<span className="font-medium">البيان:</span>{' '}
|
||||
{item.description || '-'}
|
||||
@@ -510,6 +561,36 @@ export default function PortalExpenseClaimsPage() {
|
||||
placeholder="أي ملاحظات عامة على الكشف"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
مرفق
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,application/pdf"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
attachment: e.target.files?.[0] || null,
|
||||
}))
|
||||
}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
|
||||
{form.attachment ? (
|
||||
<div className="mt-2 flex items-center justify-between rounded-lg bg-gray-50 border px-3 py-2 text-sm text-gray-700">
|
||||
<span>{form.attachment.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((prev) => ({ ...prev, attachment: null }))}
|
||||
className="text-red-600 hover:underline"
|
||||
>
|
||||
إزالة
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-gray-50 border px-4 py-3 text-sm font-medium text-gray-700">
|
||||
الإجمالي: {totalAmount.toLocaleString()}
|
||||
|
||||
@@ -7,6 +7,12 @@ import LoadingSpinner from '@/components/LoadingSpinner'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { Plus } from 'lucide-react'
|
||||
|
||||
const TIME_OPTIONS = Array.from({ length: 48 }, (_, i) => {
|
||||
const hour = Math.floor(i / 2).toString().padStart(2, '0')
|
||||
const minute = i % 2 === 0 ? '00' : '30'
|
||||
return `${hour}:${minute}`
|
||||
})
|
||||
|
||||
const LEAVE_TYPES = [
|
||||
{ value: 'ANNUAL', label: 'إجازة سنوية' },
|
||||
{ value: 'HOURLY', label: 'إجازة ساعية' },
|
||||
@@ -47,6 +53,9 @@ export default function PortalLeavePage() {
|
||||
}
|
||||
|
||||
useEffect(() => load(), [])
|
||||
const toCompanyDateTime = (date: string, time: string) => {
|
||||
return `${date}T${time}:00+03:00`
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -80,8 +89,8 @@ export default function PortalLeavePage() {
|
||||
return
|
||||
}
|
||||
|
||||
payload.startDate = `${form.leaveDate}T${form.startTime}:00`
|
||||
payload.endDate = `${form.leaveDate}T${form.endTime}:00`
|
||||
payload.startDate = `${form.leaveDate}T${form.startTime}:00+03:00`
|
||||
payload.endDate = `${form.leaveDate}T${form.endTime}:00+03:00`
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
@@ -238,22 +247,30 @@ export default function PortalLeavePage() {
|
||||
|
||||
<div>
|
||||
<label className="text-sm">من الساعة</label>
|
||||
<input
|
||||
type="time"
|
||||
<select
|
||||
value={form.startTime}
|
||||
onChange={(e) => setForm(p => ({ ...p, startTime: e.target.value }))}
|
||||
className="border p-2 rounded w-full"
|
||||
/>
|
||||
>
|
||||
<option value="">اختر الوقت</option>
|
||||
{TIME_OPTIONS.map((time) => (
|
||||
<option key={time} value={time}>{time}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm">إلى الساعة</label>
|
||||
<input
|
||||
type="time"
|
||||
<select
|
||||
value={form.endTime}
|
||||
onChange={(e) => setForm(p => ({ ...p, endTime: e.target.value }))}
|
||||
className="border p-2 rounded w-full"
|
||||
/>
|
||||
>
|
||||
<option value="">اختر الوقت</option>
|
||||
{TIME_OPTIONS.map((time) => (
|
||||
<option key={time} value={time}>{time}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -69,13 +69,33 @@ export default function ManagedExpenseClaimsPage() {
|
||||
loadClaims(statusFilter);
|
||||
}, [statusFilter]);
|
||||
|
||||
async function openAttachment(attachment: any) {
|
||||
try {
|
||||
const blob = await portalAPI.viewExpenseClaimAttachment(attachment.id);
|
||||
const blobUrl = window.URL.createObjectURL(
|
||||
new Blob([blob], { type: attachment.mimeType })
|
||||
);
|
||||
|
||||
window.open(blobUrl, '_blank');
|
||||
|
||||
setTimeout(() => {
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
}, 10000);
|
||||
} catch (error) {
|
||||
alert('تعذر فتح المرفق');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApprove(id: string) {
|
||||
const confirmed = window.confirm('هل أنت متأكد من الموافقة على طلب كشف المصاريف؟');
|
||||
if (!confirmed) return;
|
||||
const note = window.prompt(
|
||||
'ملاحظة مع الموافقة (اتركها فارغة إذا لا توجد):',
|
||||
'',
|
||||
);
|
||||
if (note === null) return;
|
||||
|
||||
try {
|
||||
setSubmittingId(id);
|
||||
await portalAPI.approveManagedExpenseClaim(id);
|
||||
await portalAPI.approveManagedExpenseClaim(id, note.trim() || undefined);
|
||||
await loadClaims(statusFilter);
|
||||
} catch (error: any) {
|
||||
alert(error?.response?.data?.message || 'تعذر تنفيذ الموافقة');
|
||||
@@ -309,6 +329,27 @@ export default function ManagedExpenseClaimsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{claim.attachments && claim.attachments.length > 0 ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="text-sm font-medium text-gray-800">المرفقات:</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{claim.attachments.map((attachment) => (
|
||||
<a
|
||||
key={attachment.id}
|
||||
href={`${process.env.NEXT_PUBLIC_API_URL}/hr/portal/expense-claims/attachments/${attachment.id}/view`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block rounded-lg border bg-white px-3 py-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
{attachment.originalName}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{claim.status === 'REJECTED' && claim.rejectedReason ? (
|
||||
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
@@ -317,6 +358,13 @@ export default function ManagedExpenseClaimsPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{claim.status === 'APPROVED' && claim.approvalNote ? (
|
||||
<div className="rounded-lg bg-green-50 px-3 py-2 text-sm text-green-700">
|
||||
<span className="font-medium">ملاحظة المعتمِد:</span>{' '}
|
||||
{claim.approvalNote}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{claim.status === 'PENDING' ? (
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<button
|
||||
|
||||
@@ -116,8 +116,17 @@ export interface ExpenseClaim {
|
||||
approvedBy?: string | null;
|
||||
approvedAt?: string | null;
|
||||
rejectedReason?: string | null;
|
||||
approvalNote?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
attachments?: Array<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
uploadedAt: string;
|
||||
}> | null;
|
||||
employee?: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
@@ -181,6 +190,17 @@ export interface Salary {
|
||||
|
||||
export const portalAPI = {
|
||||
|
||||
viewExpenseClaimAttachment: async (attachmentId: string): Promise<Blob> => {
|
||||
const response = await api.get(
|
||||
`/hr/portal/expense-claims/attachments/${attachmentId}/view`,
|
||||
{
|
||||
responseType: 'blob',
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getManagedLeaves: async (status?: string): Promise<ManagedLeave[]> => {
|
||||
const q = new URLSearchParams()
|
||||
if (status && status !== 'all') q.append('status', status)
|
||||
@@ -278,20 +298,36 @@ export const portalAPI = {
|
||||
return response.data.data || []
|
||||
},
|
||||
|
||||
submitExpenseClaim: async (data: {
|
||||
items: Array<{
|
||||
expenseDate: string;
|
||||
amount: number;
|
||||
entityName?: string;
|
||||
description: string;
|
||||
projectOrTender?: string;
|
||||
proofRef?: string;
|
||||
}>;
|
||||
description?: string;
|
||||
}): Promise<ExpenseClaim> => {
|
||||
const response = await api.post('/hr/portal/expense-claims', data);
|
||||
return response.data.data;
|
||||
},
|
||||
submitExpenseClaim: async (data: {
|
||||
items: Array<{
|
||||
expenseDate: string;
|
||||
amount: number;
|
||||
entityName?: string;
|
||||
description: string;
|
||||
projectOrTender?: string;
|
||||
proofRef?: string;
|
||||
}>;
|
||||
description?: string;
|
||||
attachment?: File | null;
|
||||
}): Promise<ExpenseClaim> => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('items', JSON.stringify(data.items));
|
||||
|
||||
if (data.description) {
|
||||
formData.append('description', data.description);
|
||||
}
|
||||
|
||||
if (data.attachment) {
|
||||
formData.append('attachment', data.attachment);
|
||||
}
|
||||
|
||||
const response = await api.post('/hr/portal/expense-claims', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getManagedExpenseClaims: async (status?: string): Promise<ExpenseClaim[]> => {
|
||||
const q = new URLSearchParams()
|
||||
@@ -300,9 +336,12 @@ export const portalAPI = {
|
||||
return response.data.data || []
|
||||
},
|
||||
|
||||
approveManagedExpenseClaim: async (id: string): Promise<ExpenseClaim> => {
|
||||
const response = await api.post(`/hr/portal/managed-expense-claims/${id}/approve`)
|
||||
return response.data.data
|
||||
approveManagedExpenseClaim: async (id: string, approvalNote?: string): Promise<ExpenseClaim> => {
|
||||
const response = await api.post(
|
||||
`/hr/portal/managed-expense-claims/${id}/approve`,
|
||||
approvalNote?.trim() ? { approvalNote: approvalNote.trim() } : {},
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
rejectManagedExpenseClaim: async (id: string, rejectedReason: string): Promise<ExpenseClaim> => {
|
||||
|
||||
Reference in New Issue
Block a user