Files
musadeq/frontend/src/pages/invoices/InvoicesPage.tsx
2026-04-18 01:42:56 +03:00

334 lines
20 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
* ════════════════════════════════════════════════════════════
*/
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Upload,
Search,
Filter,
Eye,
CheckCircle2,
Clock,
AlertCircle,
MoreVertical,
ChevronLeft,
ChevronRight,
Building2,
FileText,
Send
} 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);
const fetchData = async () => {
setIsLoading(true);
try {
// Fetch companies first so the dropdown always works
try {
const compRes = await apiClient.get('/companies');
console.log('Fetched Companies:', compRes.data);
setCompanies(compRes.data);
} catch (err) {
console.error('Failed to fetch companies', err);
}
// Fetch invoices separately
try {
const invRes = await apiClient.get('/invoices');
console.log('Fetched Invoices:', invRes.data);
setInvoices(invRes.data);
} catch (err) {
console.error('Failed to fetch invoices', err);
}
} 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 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?.includes(searchTerm)
);
const StatusBadge = ({ status }: { status: string }) => {
const config: any = {
approved: { color: 'text-emerald-700 bg-emerald-50 border-emerald-100', icon: CheckCircle2, label: 'تم التصديق' },
validated: { color: 'text-blue-700 bg-blue-50 border-blue-100', icon: CheckCircle2, label: 'جاهز للإرسال' },
extracted: { color: 'text-indigo-700 bg-indigo-50 border-indigo-100', icon: CheckCircle2, label: 'تم الاستخراج' },
uploaded: { color: 'text-amber-700 bg-amber-50 border-amber-100', icon: Clock, label: 'قيد المعالجة AI' },
extracting: { color: 'text-amber-700 bg-amber-50 border-amber-100', icon: Clock, label: 'قيد الاستخراج' },
validation_failed: { color: 'text-red-700 bg-red-50 border-red-100', icon: AlertCircle, label: 'خطأ في التحقق' },
};
const { color, icon: Icon, label } = config[status] || { color: 'text-slate-500 bg-slate-50', 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 h-full flex flex-col animate-in fade-in slide-in-from-bottom-4 duration-700">
<header className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold text-slate-900">إدارة الفواتير</h2>
<p className="text-slate-500 mt-1">عرض، معالجة، وإرسال الفواتير الضريبية لبوابة الضريبة.</p>
</div>
<button
onClick={() => setIsUploadModalOpen(true)}
className="btn-primary py-3 px-8 rounded-2xl flex items-center gap-2 shadow-xl shadow-primary-500/25 active:scale-95 transition-all"
>
<Upload className="w-5 h-5" />
رفع فاتورة جديدة
</button>
</header>
{/* ── Filter & Search Bar ──────────────────────────────── */}
<div className="flex gap-4">
<div className="flex-1 glass border-slate-200 rounded-2xl px-4 py-3 flex items-center gap-3">
<Search className="w-5 h-5 text-slate-400" />
<input
type="text"
placeholder="ابحث برقم الفاتورة، اسم الشركة، أو التاريخ..."
className="bg-transparent border-none outline-none flex-1 text-slate-800 text-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<button className="glass border-slate-200 px-6 rounded-2xl flex items-center gap-2 text-slate-600 hover:bg-slate-50 transition-all font-semibold">
<Filter className="w-4 h-4" />
فلترة
</button>
</div>
{/* ── Invoices Table ───────────────────────────────────── */}
<div className="flex-1 card-premium overflow-hidden flex flex-col bg-white border border-slate-100">
{isLoading ? (
<div className="flex-1 flex justify-center items-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
) : filteredInvoices.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center p-20 text-center">
<div className="w-24 h-24 bg-slate-50 rounded-full flex items-center justify-center mb-6 border border-slate-100">
<FileText className="w-10 h-10 text-slate-300" />
</div>
<h3 className="text-xl font-bold text-slate-900 mb-2">لا توجد فواتير بعد</h3>
<p className="text-slate-500 max-w-sm mb-8">ابدأ برفع أول فاتورة ليقوم محرك الذكاء الاصطناعي باستخراج بياناتها ومصادقتها ضريبياً.</p>
<button onClick={() => setIsUploadModalOpen(true)} className="btn-primary py-3 px-8 rounded-2xl flex items-center gap-2">
ارفع فاتورتك الأولى
</button>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-right border-collapse">
<thead className="bg-slate-50/80 border-b border-slate-100">
<tr>
<th className="px-6 py-4 text-sm font-bold text-slate-500">رقم الفاتورة</th>
<th className="px-6 py-4 text-sm font-bold text-slate-500">الشركة</th>
<th className="px-6 py-4 text-sm font-bold text-slate-500">التاريخ</th>
<th className="px-6 py-4 text-sm font-bold text-slate-500 text-left">المجموع (JOD)</th>
<th className="px-6 py-4 text-sm font-bold text-slate-500">الحالة</th>
<th className="px-6 py-4 text-sm font-bold text-slate-500 w-20 text-center">إجراءات</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{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-50/50 transition-colors group cursor-pointer"
>
<td className="px-6 py-4 font-bold text-slate-900">{inv.invoice_number || '---'}</td>
<td className="px-6 py-4 text-slate-600 font-medium">
<div className="flex items-center gap-2">
<div className="w-7 h-7 bg-slate-100 rounded-md flex items-center justify-center">
<Building2 className="w-4 h-4 text-slate-400" />
</div>
{inv.company?.name || 'شركة غير معروفة'}
</div>
</td>
<td className="px-6 py-4 text-slate-500 text-sm">
{inv.issue_date ? new Date(inv.issue_date).toLocaleDateString('ar-JO') : '---'}
</td>
<td className="px-6 py-4 font-mono font-bold text-slate-800 text-left">
{Number(inv.total_amount).toLocaleString('en-US', { minimumFractionDigits: 3 })}
</td>
<td className="px-6 py-4"><StatusBadge status={inv.status} /></td>
<td className="px-6 py-4 text-center">
<div className="flex items-center justify-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-2 text-slate-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-all">
<Eye className="w-4 h-4" />
</button>
{inv.status === 'validated' && (
<button className="p-2 text-emerald-500 hover:text-emerald-600 hover:bg-emerald-50 rounded-lg transition-all" title="إرسال لجو فوترة">
<Send className="w-4 h-4" />
</button>
)}
<button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-all">
<MoreVertical 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-50/50 border-t border-slate-100 flex items-center justify-between">
<p className="text-sm text-slate-500">عرض {filteredInvoices.length} فواتير</p>
<div className="flex gap-2">
<button className="p-2 text-slate-400 hover:text-slate-600 disabled:opacity-30 border border-slate-200 rounded-xl bg-white shadow-sm">
<ChevronRight className="w-5 h-5" />
</button>
<button className="p-2 text-slate-400 hover:text-slate-600 disabled:opacity-30 border border-slate-200 rounded-xl bg-white shadow-sm">
<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-900/60 backdrop-blur-sm">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-white rounded-[32px] p-8 w-full max-w-xl shadow-2xl overflow-hidden relative"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-primary-50 rounded-full -mr-16 -mt-16 opacity-50" />
<h3 className="text-2xl font-bold text-slate-900 mb-2 relative">رفع فاتورة جديدة</h3>
<p className="text-slate-500 mb-8 relative">اختر الشركة وملف الفاتورة (PDF أو صورة) وسيقوم الذكاء الاصطناعي بالباقي.</p>
<form onSubmit={handleUpload} className="space-y-6 relative">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">اختر الشركة</label>
<select
required
value={selectedCompanyId}
onChange={e => setSelectedCompanyId(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-2xl px-5 py-4 outline-none focus:border-primary-500 focus:ring-4 focus:ring-primary-500/10 transition-all 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-700 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-200 rounded-2xl p-10 flex flex-col items-center group-hover:border-primary-400 group-hover:bg-primary-50/30 transition-all bg-slate-50/50">
<div className="w-14 h-14 bg-white rounded-2xl shadow-sm flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
<Upload className="w-7 h-7 text-primary-600" />
</div>
<p className="font-bold text-slate-800">
{selectedFile ? selectedFile.name : 'اسحب الملف هنا أو انقر للاختيار'}
</p>
<p className="text-sm text-slate-400 mt-1">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-100 text-slate-700 font-bold py-4 rounded-2xl hover:bg-slate-200 transition-all"
>
إلغاء
</button>
<button
type="submit"
disabled={isUploading || !selectedCompanyId || !selectedFile}
className="flex-[2] btn-primary py-4 rounded-2xl shadow-lg shadow-primary-500/30 disabled:opacity-50 flex items-center justify-center gap-3"
>
{isUploading ? (
<>
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
جاري الرفع والمعالجة...
</>
) : (
<>
<Upload className="w-5 h-5" />
ابدأ المعالجة الآن
</>
)}
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
};