import { useEffect, useState } from 'react' import api from '../api' import toast from 'react-hot-toast' const STATUS_LABELS = { pending: { label: 'En attente', class: 'bg-yellow-100 text-yellow-800' }, approved: { label: 'Approuvée', class: 'bg-green-100 text-green-800' }, rejected: { label: 'Rejetée', class: 'bg-red-100 text-red-800' }, } function WideModal({ title, onClose, children }) { return (

{title}

{children}
) } function Modal({ title, onClose, children }) { return (

{title}

{children}
) } export default function InvoiceScanner() { const [properties, setProperties] = useState([]) const [invoices, setInvoices] = useState([]) const [selectedProperty, setSelectedProperty] = useState('') const [filterStatus, setFilterStatus] = useState('pending') const [filterYear, setFilterYear] = useState('') const [scanning, setScanning] = useState(false) const [scanResult, setScanResult] = useState(null) const [stats, setStats] = useState(null) const [editInvoice, setEditInvoice] = useState(null) const [editForm, setEditForm] = useState({}) const [previewId, setPreviewId] = useState(null) const [previewUrl, setPreviewUrl] = useState(null) const [categories, setCategories] = useState([]) const [selected, setSelected] = useState(new Set()) const [filterText, setFilterText] = useState('') const [editPreviewUrl, setEditPreviewUrl] = useState(null) const loadProperties = () => api.get('/properties').then(r => setProperties(r.data)) const loadStats = () => api.get('/scan/stats').then(r => setStats(r.data)) const loadCategories = () => api.get('/categories').then(r => setCategories(r.data)) const loadInvoices = () => { const params = new URLSearchParams() if (selectedProperty) params.set('property_id', selectedProperty) if (filterStatus) params.set('status', filterStatus) if (filterYear) params.set('year', filterYear) api.get(`/scan/invoices?${params}`).then(r => setInvoices(r.data)) } useEffect(() => { loadProperties(); loadStats(); loadCategories() }, []) useEffect(() => { loadInvoices(); setSelected(new Set()) }, [selectedProperty, filterStatus, filterYear]) const propertiesWithScan = properties.filter(p => p.scan_directory) const handleScan = async (propertyId) => { setScanning(true) setScanResult(null) try { const r = await api.post(`/scan/${propertyId}`) setScanResult(r.data) toast.success(`Scan terminé : ${r.data.new} nouvelle(s) facture(s)`) loadInvoices() loadStats() } catch (err) { toast.error(err.response?.data?.error || 'Erreur lors du scan') } finally { setScanning(false) } } const handleScanAll = async () => { setScanning(true) let totalNew = 0 for (const p of propertiesWithScan) { try { const r = await api.post(`/scan/${p.id}`) totalNew += r.data.new } catch {} } toast.success(`Scan global terminé : ${totalNew} nouvelle(s) facture(s)`) setScanning(false) loadInvoices() loadStats() } const openEdit = async (inv) => { setEditForm({ detected_supplier: inv.detected_supplier || '', detected_category: inv.detected_category || 'autre', detected_amount: inv.detected_amount || '', detected_label: inv.detected_label || '', year: inv.year || new Date().getFullYear(), notes: inv.notes || '', recoverable_type: 'recoverable', }) setEditInvoice(inv) // Load preview for edit modal try { const res = await api.get(`/scan/invoices/${inv.id}/preview`, { responseType: 'blob' }) const url = URL.createObjectURL(res.data) setEditPreviewUrl(url) } catch { setEditPreviewUrl(null) } } const closeEdit = () => { if (editPreviewUrl) URL.revokeObjectURL(editPreviewUrl) setEditPreviewUrl(null) setEditInvoice(null) } const handleDownload = async (inv) => { try { const res = await api.get(`/scan/invoices/${inv.id}/preview`, { responseType: 'blob' }) const url = URL.createObjectURL(res.data) const a = document.createElement('a') a.href = url a.download = inv.file_path.split('/').pop() document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) } catch { toast.error('Impossible de télécharger le fichier') } } const handleSave = async () => { try { await api.put(`/scan/invoices/${editInvoice.id}`, editForm) toast.success('Facture mise à jour') setEditInvoice(null) loadInvoices() } catch (err) { toast.error(err.response?.data?.error || 'Erreur') } } const handleApprove = async (inv) => { const target = editInvoice || inv const form = editInvoice ? editForm : {} if (!target.detected_amount && !form.detected_amount) { toast.error('Veuillez renseigner le montant avant d\'approuver') return } try { if (editInvoice) { await api.put(`/scan/invoices/${target.id}`, editForm) } await api.post(`/scan/invoices/${target.id}/approve`, { recoverable_type: form.recoverable_type || 'recoverable' }) toast.success('Facture approuvée et intégrée aux charges !') setEditInvoice(null) loadInvoices() loadStats() } catch (err) { toast.error(err.response?.data?.error || 'Erreur') } } const handleReject = async (inv) => { if (!confirm('Rejeter cette facture ?')) return try { await api.post(`/scan/invoices/${inv.id}/reject`) toast.success('Facture rejetée') loadInvoices() loadStats() } catch (err) { toast.error(err.response?.data?.error || 'Erreur') } } const handleDelete = async (inv) => { if (!confirm('Supprimer cette entrée ?')) return try { await api.delete(`/scan/invoices/${inv.id}`) toast.success('Entrée supprimée') loadInvoices() loadStats() } catch (err) { toast.error(err.response?.data?.error || 'Erreur') } } const openPreview = async (id) => { try { const res = await api.get(`/scan/invoices/${id}/preview`, { responseType: 'blob' }) const url = URL.createObjectURL(res.data) setPreviewUrl(url) setPreviewId(id) } catch { toast.error('Impossible de charger l\'aperçu') } } const closePreview = () => { if (previewUrl) URL.revokeObjectURL(previewUrl) setPreviewUrl(null) setPreviewId(null) } const years = [...new Set(invoices.map(i => i.year).filter(Boolean))].sort((a, b) => b - a) const allYears = years.length > 0 ? years : [new Date().getFullYear()] // Client-side text filter on filename const filteredInvoices = filterText ? invoices.filter(inv => inv.file_path.toLowerCase().includes(filterText.toLowerCase())) : invoices // Selection helpers const toggleSelect = (id) => { setSelected(prev => { const next = new Set(prev) next.has(id) ? next.delete(id) : next.add(id) return next }) } const toggleSelectAll = () => { if (selected.size === filteredInvoices.length) { setSelected(new Set()) } else { setSelected(new Set(filteredInvoices.map(i => i.id))) } } const selectedInvoices = filteredInvoices.filter(i => selected.has(i.id)) const handleBulkReject = async () => { const pending = selectedInvoices.filter(i => i.status === 'pending') if (pending.length === 0) return toast.error('Aucune facture en attente sélectionnée') if (!confirm(`Rejeter ${pending.length} facture(s) ?`)) return let ok = 0 for (const inv of pending) { try { await api.post(`/scan/invoices/${inv.id}/reject`); ok++ } catch {} } toast.success(`${ok} facture(s) rejetée(s)`) setSelected(new Set()) loadInvoices() loadStats() } const handleBulkDelete = async () => { const deletable = selectedInvoices.filter(i => i.status !== 'approved') if (deletable.length === 0) return toast.error('Aucune facture supprimable sélectionnée') if (!confirm(`Supprimer ${deletable.length} facture(s) ?`)) return let ok = 0 for (const inv of deletable) { try { await api.delete(`/scan/invoices/${inv.id}`); ok++ } catch {} } toast.success(`${ok} facture(s) supprimée(s)`) setSelected(new Set()) loadInvoices() loadStats() } return (

📄 Scanner de factures

Détection automatique et approbation des factures

{propertiesWithScan.length > 0 && ( )}
{/* Stats */} {stats && (

{stats.pending}

En attente

{stats.approved}

Approuvées

{stats.rejected}

Rejetées

{stats.total}

Total

)} {/* Scan buttons per property */} {propertiesWithScan.length > 0 && (

Biens avec dossier de scan configuré

{propertiesWithScan.map(p => (
{p.name} {p.scan_directory} {p.scan_cron_enabled ? Auto : null}
))}
{scanResult && (
Résultat : {scanResult.scanned} fichier(s) analysé(s), {scanResult.new} nouveau(x), {scanResult.duplicates} doublon(s), {scanResult.errors} erreur(s)
)}
)} {propertiesWithScan.length === 0 && (
📂

Aucun bien n'a de dossier de scan configuré.

Rendez-vous dans la page Biens pour configurer un dossier de scan.

)} {/* Filters */}
setFilterText(e.target.value)} placeholder="🔍 Filtrer par nom de fichier..." />
{/* Selection toolbar */} {selected.size > 0 && (
{selected.size} sélectionnée(s)
)} {/* Invoice list */} {filteredInvoices.length === 0 ? (

Aucune facture scannée pour les filtres sélectionnés

) : (
{/* Select all header */}
0} onChange={toggleSelectAll} className="rounded border-gray-300 text-blue-600" /> Tout sélectionner ({filteredInvoices.length})
{filteredInvoices.map(inv => (
toggleSelect(inv.id)} className="rounded border-gray-300 text-blue-600 shrink-0 mt-1 sm:mt-0" />
{STATUS_LABELS[inv.status]?.label} {inv.detected_label || 'Facture à identifier'} {inv.year && {inv.year}}
🏠 {inv.property_name} {inv.detected_supplier && 🏢 {inv.detected_supplier}} {inv.detected_category && 📁 {categories.find(c => c.value === inv.detected_category)?.label || inv.detected_category}} {inv.detected_amount != null && 💰 {inv.detected_amount.toFixed(2)} €}

{inv.file_path.split('/').slice(-2).join('/')}

{inv.status === 'pending' && ( <> )} {inv.status !== 'approved' && ( )}
))}
)} {/* Preview modal */} {previewId && previewUrl && (