Files
musadeq/frontend/src/pages/invoices/InvoicesPage.tsx

429 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Invoices Management Page (Premium Dark)
* ════════════════════════════════════════════════════════════
*/
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Upload,
Search,
Filter,
Eye,
CheckCircle2,
Clock,
AlertCircle,
ChevronLeft,
ChevronRight,
Building2,
FileText,
Send,
Download,
Trash2,
Loader2,
X
} from 'lucide-react';
import apiClient from '../../api/client';
export const InvoicesPage = () => {
const [invoices, setInvoices] = useState<any[]>([]);
const [companies, setCompanies] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
// Upload Form State
const [selectedCompanyId, setSelectedCompanyId] = useState('');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
// View Modal State
const [viewingInvoice, setViewingInvoice] = useState<any | null>(null);
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState<string | null>(null);
const [submitLoading, setSubmitLoading] = useState<string | null>(null);
const fetchData = async () => {
setIsLoading(true);
try {
const [compRes, invRes] = await Promise.all([
apiClient.get('/companies').catch(() => ({ data: [] })),
apiClient.get('/invoices').catch(() => ({ data: [] }))
]);
setCompanies(compRes.data);
setInvoices(invRes.data);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const handleUpload = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedCompanyId || !selectedFile) return;
setIsUploading(true);
const formData = new FormData();
formData.append('file', selectedFile);
try {
await apiClient.post(`/invoices/upload/${selectedCompanyId}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
setIsUploadModalOpen(false);
setSelectedFile(null);
setSelectedCompanyId('');
fetchData();
} catch (error) {
console.error('Upload failed', error);
alert('حدث خطأ أثناء رفع الفاتورة');
} finally {
setIsUploading(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('هل أنت متأكد من حذف هذه الفاتورة نهائياً؟')) return;
setDeleteLoading(id);
try {
await apiClient.post(`/invoices/${id}/delete`);
fetchData();
} catch (error) {
alert('فشل حذف الفاتورة');
} finally {
setDeleteLoading(null);
}
};
const handleSubmitToJoFotara = async (inv: any) => {
setSubmitLoading(inv.id);
try {
await apiClient.post(`/invoices/${inv.id}/submit`);
alert('تم الإرسال لـ جو فوترة بنجاح! 🎉');
fetchData();
} catch (err) {
alert('فشل الإرسال لـ جو فوترة. تأكد من إعدادات الشركة وصحة البيانات.');
} finally {
setSubmitLoading(null);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setSelectedFile(e.target.files[0]);
}
};
const filteredInvoices = invoices.filter(inv =>
inv.invoice_number?.includes(searchTerm) ||
inv.company?.name?.toLowerCase().includes(searchTerm.toLowerCase())
);
const StatusBadge = ({ invoice }: { invoice: any }) => {
const status = invoice.status;
const config: any = {
approved: { color: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20', icon: CheckCircle2, label: 'تم التصديق' },
validated: { color: 'text-blue-400 bg-blue-500/10 border-blue-500/20', icon: CheckCircle2, label: 'جاهز للإرسال' },
extracted: { color: 'text-indigo-400 bg-indigo-500/10 border-indigo-500/20', icon: CheckCircle2, label: 'تم الاستخراج' },
uploaded: { color: 'text-amber-400 bg-amber-500/10 border-amber-500/20', icon: Clock, label: 'قيد المعالجة AI' },
extracting: { color: 'text-amber-400 bg-amber-500/10 border-amber-500/20', icon: Clock, label: 'قيد الاستخراج' },
validation_failed: { color: 'text-red-400 bg-red-500/10 border-red-500/20', icon: AlertCircle, label: 'خطأ في التحقق' },
};
const { color, icon: Icon, label } = config[status] || { color: 'text-slate-400 bg-slate-800', icon: Clock, label: status };
return (
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold border ${color} uppercase tracking-tight`}>
<Icon className="w-3 h-3" />
{label}
</span>
);
};
return (
<div className="space-y-8 animate-in fade-in duration-700">
<header className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-black text-white">إدارة الفواتير</h2>
<p className="text-slate-400 mt-1">عرض، معالجة، وإرسال الفواتير الضريبية لبوابة الضريبة.</p>
</div>
<button
onClick={() => setIsUploadModalOpen(true)}
className="bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-8 rounded-xl flex items-center gap-2 shadow-lg shadow-emerald-500/20 transition-all active:scale-95"
>
<Upload className="w-5 h-5" />
رفع فاتورة جديدة
</button>
</header>
{/* ── Filter & Search Bar ──────────────────────────────── */}
<div className="flex gap-4">
<div className="flex-1 bg-slate-900/50 backdrop-blur-xl border border-slate-800/60 rounded-xl px-4 py-3 flex items-center gap-3">
<Search className="w-5 h-5 text-slate-500" />
<input
type="text"
placeholder="ابحث برقم الفاتورة، اسم الشركة، أو التاريخ..."
className="bg-transparent border-none outline-none flex-1 text-slate-200 text-sm placeholder-slate-500"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<button className="bg-slate-800/60 border border-slate-700/50 px-6 rounded-xl flex items-center gap-2 text-slate-300 hover:bg-slate-800 transition-all font-bold text-sm">
<Filter className="w-4 h-4" />
فلترة
</button>
</div>
{/* ── Invoices Table ───────────────────────────────────── */}
<div className="bg-slate-900/50 backdrop-blur-xl border border-slate-800/60 rounded-2xl overflow-hidden min-h-[400px] flex flex-col">
{isLoading ? (
<div className="flex-1 flex flex-col justify-center items-center">
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin mb-4" />
<p className="text-slate-500 text-sm">جاري جلب الفواتير...</p>
</div>
) : filteredInvoices.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center p-20 text-center">
<div className="w-20 h-20 bg-slate-800 rounded-2xl flex items-center justify-center mb-6 border border-slate-700">
<FileText className="w-10 h-10 text-slate-600" />
</div>
<h3 className="text-xl font-bold text-white mb-2">لا توجد فواتير بعد</h3>
<p className="text-slate-500 max-w-sm mb-8">ابدأ برفع أول فاتورة ليقوم محرك الذكاء الاصطناعي باستخراج بياناتها ومصادقتها ضريبياً.</p>
<button onClick={() => setIsUploadModalOpen(true)} className="bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-8 rounded-xl transition-all">
ارفع فاتورتك الأولى
</button>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-right">
<thead className="bg-slate-800/50 border-b border-slate-800/60">
<tr>
<th className="px-6 py-4 text-xs font-bold text-slate-400">رقم الفاتورة</th>
<th className="px-6 py-4 text-xs font-bold text-slate-400">الشركة</th>
<th className="px-6 py-4 text-xs font-bold text-slate-400">التاريخ</th>
<th className="px-6 py-4 text-xs font-bold text-slate-400 text-left">المجموع (JOD)</th>
<th className="px-6 py-4 text-xs font-bold text-slate-400">الحالة</th>
<th className="px-6 py-4 text-xs font-bold text-slate-400 text-center">إجراءات</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{filteredInvoices.map((inv, idx) => (
<motion.tr
key={inv.id}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
className="hover:bg-slate-800/30 transition-colors group cursor-pointer"
onClick={() => {
setViewingInvoice(inv);
setIsViewModalOpen(true);
}}
>
<td className="px-6 py-4 font-bold text-white">{inv.invoice_number || '---'}</td>
<td className="px-6 py-4 text-slate-400">
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-slate-600" />
{inv.company?.name || '---'}
</div>
</td>
<td className="px-6 py-4 text-slate-500 text-sm">
{inv.invoice_date ? new Date(inv.invoice_date).toLocaleDateString('ar-JO') : '---'}
</td>
<td className="px-6 py-4 font-mono font-bold text-emerald-400 text-left">
{Number(inv.grand_total).toLocaleString('en-US', { minimumFractionDigits: 3 })}
</td>
<td className="px-6 py-4"><StatusBadge invoice={inv} /></td>
<td className="px-6 py-4 text-center">
<div className="flex items-center justify-center gap-2" onClick={e => e.stopPropagation()}>
<button
onClick={() => { setViewingInvoice(inv); setIsViewModalOpen(true); }}
className="p-2 text-slate-500 hover:text-emerald-400 hover:bg-emerald-500/10 rounded-lg transition-all"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(inv.id)}
className="p-2 text-slate-500 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-all"
>
{deleteLoading === inv.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
</button>
</div>
</td>
</motion.tr>
))}
</tbody>
</table>
</div>
)}
{/* ── Pagination ───────────────────────────────────────── */}
{!isLoading && filteredInvoices.length > 0 && (
<footer className="px-6 py-4 bg-slate-800/30 border-t border-slate-800/60 flex items-center justify-between mt-auto">
<p className="text-sm text-slate-500">عرض {filteredInvoices.length} فواتير</p>
<div className="flex gap-2">
<button className="p-2 text-slate-500 hover:text-white border border-slate-700 rounded-xl bg-slate-800 transition-all">
<ChevronRight className="w-5 h-5" />
</button>
<button className="p-2 text-slate-500 hover:text-white border border-slate-700 rounded-xl bg-slate-800 transition-all">
<ChevronLeft className="w-5 h-5" />
</button>
</div>
</footer>
)}
</div>
{/* ── Upload Modal ─────────────────────────────────────── */}
<AnimatePresence>
{isUploadModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-slate-900 border border-slate-800 p-8 w-full max-w-xl rounded-3xl shadow-2xl relative"
>
<button onClick={() => setIsUploadModalOpen(false)} className="absolute top-6 left-6 text-slate-500 hover:text-white transition-colors">
<X className="w-6 h-6" />
</button>
<h3 className="text-2xl font-bold text-white mb-2">رفع فاتورة جديدة</h3>
<p className="text-slate-400 mb-8 text-sm">اختر الشركة وملف الفاتورة (PDF أو صورة) وسيقوم الذكاء الاصطناعي بالباقي.</p>
<form onSubmit={handleUpload} className="space-y-6">
<div>
<label className="block text-sm font-bold text-slate-300 mb-2">اختر الشركة</label>
<select
required
value={selectedCompanyId}
onChange={e => setSelectedCompanyId(e.target.value)}
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-5 py-4 outline-none focus:border-emerald-500/50 transition-all text-white appearance-none cursor-pointer"
>
<option value="">-- اختر شركة --</option>
{companies.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-bold text-slate-300 mb-2">ملف الفاتورة</label>
<div className="relative group">
<input
type="file"
required
onChange={handleFileChange}
accept=".pdf,image/*"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
/>
<div className="border-2 border-dashed border-slate-700 rounded-xl p-10 flex flex-col items-center group-hover:border-emerald-500/50 group-hover:bg-emerald-500/5 transition-all bg-slate-800/50">
<Upload className="w-10 h-10 text-slate-600 mb-4 transition-colors group-hover:text-emerald-500" />
<p className="font-bold text-slate-300 text-center">
{selectedFile ? selectedFile.name : 'اسحب الملف هنا أو انقر للاختيار'}
</p>
<p className="text-xs text-slate-500 mt-2">PDF, JPG, PNG (حد أقصى 10MB)</p>
</div>
</div>
</div>
<div className="flex gap-4 pt-4">
<button
type="button"
onClick={() => setIsUploadModalOpen(false)}
className="flex-1 bg-slate-800 text-slate-300 font-bold py-4 rounded-xl hover:bg-slate-700 transition-all"
>
إلغاء
</button>
<button
type="submit"
disabled={isUploading || !selectedCompanyId || !selectedFile}
className="flex-[2] bg-emerald-500 text-slate-950 font-bold py-4 rounded-xl shadow-lg shadow-emerald-500/20 disabled:opacity-50 flex items-center justify-center gap-3 transition-all"
>
{isUploading ? <Loader2 className="w-5 h-5 animate-spin" /> : <Upload className="w-5 h-5" />}
{isUploading ? 'جاري المعالجة...' : 'ابدأ المعالجة الآن'}
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
{/* ── View Invoice Modal ─────────────────────────────────── */}
<AnimatePresence>
{isViewModalOpen && viewingInvoice && (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-slate-950/90 backdrop-blur-md">
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-slate-900 border border-slate-800 w-full max-w-5xl h-[90vh] rounded-[32px] shadow-2xl flex flex-col overflow-hidden"
>
<header className="px-8 py-6 border-b border-slate-800 flex items-center justify-between">
<div>
<h3 className="text-2xl font-bold text-white">معاينة الفاتورة</h3>
<p className="text-slate-500 text-sm">رقم: {viewingInvoice.invoice_number || '---'} {viewingInvoice.company?.name}</p>
</div>
<button
onClick={() => setIsViewModalOpen(false)}
className="w-10 h-10 flex items-center justify-center rounded-xl bg-slate-800 text-slate-500 hover:text-white transition-all"
>
<X className="w-6 h-6" />
</button>
</header>
<div className="flex-1 overflow-auto bg-slate-950 p-8 flex justify-center items-center">
<div className="w-full h-full max-w-4xl bg-white rounded-xl overflow-hidden shadow-2xl relative">
<iframe
src={`${apiClient.defaults.baseURL}/invoices/${viewingInvoice.id}/file?token=${localStorage.getItem('access_token')}#toolbar=0`}
className="w-full h-full border-none"
title="Invoice Preview"
/>
{/* Fallback overlay in case of loading issues */}
<div className="absolute inset-0 pointer-events-none flex items-center justify-center bg-slate-900/10 backdrop-blur-[2px] opacity-0 hover:opacity-100 transition-opacity">
<p className="bg-slate-900/80 text-white px-4 py-2 rounded-lg text-xs">جاري عرض الفاتورة...</p>
</div>
</div>
</div>
<footer className="px-8 py-6 border-t border-slate-800 bg-slate-900/50 flex items-center justify-between">
<div className="flex items-center gap-8">
<div>
<p className="text-[10px] uppercase tracking-widest text-slate-500 font-bold mb-1">المجموع الكلي</p>
<p className="text-xl font-black text-emerald-400">{Number(viewingInvoice.grand_total).toLocaleString('en-US', { minimumFractionDigits: 3 })} JOD</p>
</div>
<div className="w-px h-10 bg-slate-800" />
<div>
<p className="text-[10px] uppercase tracking-widest text-slate-500 font-bold mb-1">الحالة</p>
<StatusBadge invoice={viewingInvoice} />
</div>
</div>
<div className="flex gap-4">
<button
onClick={() => {
const token = localStorage.getItem('access_token');
window.open(`${apiClient.defaults.baseURL}/invoices/${viewingInvoice.id}/file?token=${token}`, '_blank');
}}
className="px-6 py-3 rounded-xl bg-slate-800 border border-slate-700 text-slate-300 font-bold flex items-center gap-2 hover:bg-slate-700 transition-all"
>
<Download className="w-4 h-4" />
تحميل
</button>
<button
onClick={() => handleSubmitToJoFotara(viewingInvoice)}
className="bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold px-8 py-3 rounded-xl flex items-center gap-2 shadow-lg shadow-emerald-500/20 transition-all"
>
{submitLoading === viewingInvoice.id ? <Loader2 className="w-5 h-5 animate-spin" /> : <Send className="w-4 h-4" />}
إرسال لجو فوترة
</button>
</div>
</footer>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
};