|
|
@@ -0,0 +1,366 @@
|
|
|
+import { useEffect, useState } from 'react'
|
|
|
+import api from '../api'
|
|
|
+import toast from 'react-hot-toast'
|
|
|
+
|
|
|
+const CATEGORIES = [
|
|
|
+ { value: 'eau', label: 'Eau' },
|
|
|
+ { value: 'gaz', label: 'Gaz' },
|
|
|
+ { value: 'electricite', label: 'Électricité' },
|
|
|
+ { value: 'entretien', label: 'Entretien' },
|
|
|
+ { value: 'ascenseur', label: 'Ascenseur' },
|
|
|
+ { value: 'ordures', label: 'Ordures ménagères' },
|
|
|
+ { value: 'assurance', label: 'Assurance' },
|
|
|
+ { value: 'autre', label: 'Autre' },
|
|
|
+]
|
|
|
+
|
|
|
+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 Modal({ title, onClose, children }) {
|
|
|
+ return (
|
|
|
+ <div className="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4">
|
|
|
+ <div className="bg-white rounded-t-2xl sm:rounded-2xl shadow-xl w-full sm:max-w-2xl max-h-[92vh] flex flex-col">
|
|
|
+ <div className="flex items-center justify-between p-4 sm:p-6 border-b shrink-0">
|
|
|
+ <h2 className="text-lg font-semibold">{title}</h2>
|
|
|
+ <button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none">×</button>
|
|
|
+ </div>
|
|
|
+ <div className="p-4 sm:p-6 overflow-y-auto">{children}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+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 loadProperties = () => api.get('/properties').then(r => setProperties(r.data))
|
|
|
+ const loadStats = () => api.get('/scan/stats').then(r => setStats(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() }, [])
|
|
|
+ useEffect(() => { loadInvoices() }, [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 = (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)
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 years = [...new Set(invoices.map(i => i.year).filter(Boolean))].sort((a, b) => b - a)
|
|
|
+ const allYears = years.length > 0 ? years : [new Date().getFullYear()]
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div>
|
|
|
+ <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-8 gap-4">
|
|
|
+ <div>
|
|
|
+ <h1 className="text-2xl font-bold text-gray-900">📄 Scanner de factures</h1>
|
|
|
+ <p className="text-gray-500 mt-1">Détection automatique et approbation des factures</p>
|
|
|
+ </div>
|
|
|
+ {propertiesWithScan.length > 0 && (
|
|
|
+ <button onClick={handleScanAll} disabled={scanning} className="btn-primary whitespace-nowrap">
|
|
|
+ {scanning ? '⏳ Scan en cours...' : '🔍 Scanner tous les biens'}
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Stats */}
|
|
|
+ {stats && (
|
|
|
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
|
|
|
+ <div className="card text-center">
|
|
|
+ <p className="text-2xl font-bold text-yellow-600">{stats.pending}</p>
|
|
|
+ <p className="text-xs text-gray-500">En attente</p>
|
|
|
+ </div>
|
|
|
+ <div className="card text-center">
|
|
|
+ <p className="text-2xl font-bold text-green-600">{stats.approved}</p>
|
|
|
+ <p className="text-xs text-gray-500">Approuvées</p>
|
|
|
+ </div>
|
|
|
+ <div className="card text-center">
|
|
|
+ <p className="text-2xl font-bold text-red-600">{stats.rejected}</p>
|
|
|
+ <p className="text-xs text-gray-500">Rejetées</p>
|
|
|
+ </div>
|
|
|
+ <div className="card text-center">
|
|
|
+ <p className="text-2xl font-bold text-gray-700">{stats.total}</p>
|
|
|
+ <p className="text-xs text-gray-500">Total</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Scan buttons per property */}
|
|
|
+ {propertiesWithScan.length > 0 && (
|
|
|
+ <div className="card mb-6">
|
|
|
+ <h2 className="font-semibold text-gray-800 mb-3">Biens avec dossier de scan configuré</h2>
|
|
|
+ <div className="space-y-2">
|
|
|
+ {propertiesWithScan.map(p => (
|
|
|
+ <div key={p.id} className="flex items-center justify-between bg-gray-50 rounded-lg px-4 py-2">
|
|
|
+ <div>
|
|
|
+ <span className="font-medium text-gray-800">{p.name}</span>
|
|
|
+ <span className="text-xs text-gray-400 ml-2">{p.scan_directory}</span>
|
|
|
+ {p.scan_cron_enabled ? <span className="badge-blue ml-2 text-xs">Auto</span> : null}
|
|
|
+ </div>
|
|
|
+ <button onClick={() => handleScan(p.id)} disabled={scanning} className="text-sm text-blue-600 hover:text-blue-800 font-medium">
|
|
|
+ 🔍 Scanner
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ {scanResult && (
|
|
|
+ <div className="mt-3 p-3 bg-blue-50 rounded-lg text-sm">
|
|
|
+ Résultat : {scanResult.scanned} fichier(s) analysé(s), <strong>{scanResult.new} nouveau(x)</strong>, {scanResult.duplicates} doublon(s), {scanResult.errors} erreur(s)
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {propertiesWithScan.length === 0 && (
|
|
|
+ <div className="card text-center py-16 mb-6">
|
|
|
+ <div className="text-5xl mb-4">📂</div>
|
|
|
+ <p className="text-gray-500">Aucun bien n'a de dossier de scan configuré.</p>
|
|
|
+ <p className="text-gray-400 text-sm mt-1">Rendez-vous dans la page Biens pour configurer un dossier de scan.</p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Filters */}
|
|
|
+ <div className="flex flex-wrap gap-3 mb-4">
|
|
|
+ <select className="input w-auto" value={selectedProperty} onChange={e => setSelectedProperty(e.target.value)}>
|
|
|
+ <option value="">Tous les biens</option>
|
|
|
+ {properties.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
|
|
+ </select>
|
|
|
+ <select className="input w-auto" value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
|
|
|
+ <option value="">Tous les statuts</option>
|
|
|
+ <option value="pending">En attente</option>
|
|
|
+ <option value="approved">Approuvées</option>
|
|
|
+ <option value="rejected">Rejetées</option>
|
|
|
+ </select>
|
|
|
+ <select className="input w-auto" value={filterYear} onChange={e => setFilterYear(e.target.value)}>
|
|
|
+ <option value="">Toutes les années</option>
|
|
|
+ {allYears.map(y => <option key={y} value={y}>{y}</option>)}
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Invoice list */}
|
|
|
+ {invoices.length === 0 ? (
|
|
|
+ <div className="card text-center py-12">
|
|
|
+ <p className="text-gray-400">Aucune facture scannée pour les filtres sélectionnés</p>
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <div className="space-y-3">
|
|
|
+ {invoices.map(inv => (
|
|
|
+ <div key={inv.id} className="card hover:shadow-md transition-shadow">
|
|
|
+ <div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
|
|
+ <div className="flex-1 min-w-0">
|
|
|
+ <div className="flex items-center gap-2 mb-1">
|
|
|
+ <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_LABELS[inv.status]?.class}`}>
|
|
|
+ {STATUS_LABELS[inv.status]?.label}
|
|
|
+ </span>
|
|
|
+ <span className="text-sm font-medium text-gray-800 truncate">{inv.detected_label || 'Facture à identifier'}</span>
|
|
|
+ {inv.year && <span className="text-xs text-gray-400">{inv.year}</span>}
|
|
|
+ </div>
|
|
|
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-500">
|
|
|
+ <span>🏠 {inv.property_name}</span>
|
|
|
+ {inv.detected_supplier && <span>🏢 {inv.detected_supplier}</span>}
|
|
|
+ {inv.detected_category && <span>📁 {CATEGORIES.find(c => c.value === inv.detected_category)?.label || inv.detected_category}</span>}
|
|
|
+ {inv.detected_amount != null && <span className="font-medium text-gray-700">💰 {inv.detected_amount.toFixed(2)} €</span>}
|
|
|
+ </div>
|
|
|
+ <p className="text-xs text-gray-400 mt-1 truncate" title={inv.file_path}>{inv.file_path.split('/').slice(-2).join('/')}</p>
|
|
|
+ </div>
|
|
|
+ <div className="flex items-center gap-2 shrink-0">
|
|
|
+ <button onClick={() => setPreviewId(inv.id)} className="text-sm text-gray-500 hover:text-blue-600" title="Prévisualiser">👁️</button>
|
|
|
+ {inv.status === 'pending' && (
|
|
|
+ <>
|
|
|
+ <button onClick={() => openEdit(inv)} className="text-sm text-blue-600 hover:text-blue-800" title="Modifier et approuver">✏️</button>
|
|
|
+ <button onClick={() => handleApprove(inv)} className="text-sm text-green-600 hover:text-green-800" title="Approuver">✅</button>
|
|
|
+ <button onClick={() => handleReject(inv)} className="text-sm text-red-600 hover:text-red-800" title="Rejeter">❌</button>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ {inv.status !== 'approved' && (
|
|
|
+ <button onClick={() => handleDelete(inv)} className="text-sm text-gray-400 hover:text-red-600" title="Supprimer">🗑️</button>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Preview modal */}
|
|
|
+ {previewId && (
|
|
|
+ <Modal title="Prévisualisation" onClose={() => setPreviewId(null)}>
|
|
|
+ <iframe
|
|
|
+ src={`/api/scan/invoices/${previewId}/preview`}
|
|
|
+ className="w-full h-[70vh] border rounded-lg"
|
|
|
+ title="Aperçu facture"
|
|
|
+ />
|
|
|
+ </Modal>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Edit/Approve modal */}
|
|
|
+ {editInvoice && (
|
|
|
+ <Modal title="Vérifier et approuver la facture" onClose={() => setEditInvoice(null)}>
|
|
|
+ <div className="space-y-4">
|
|
|
+ <div className="bg-gray-50 rounded-lg p-3 text-xs text-gray-500">
|
|
|
+ <p className="truncate">📄 {editInvoice.file_path.split('/').slice(-2).join('/')}</p>
|
|
|
+ <button onClick={() => { setPreviewId(editInvoice.id) }} className="text-blue-600 hover:underline mt-1">Prévisualiser le fichier</button>
|
|
|
+ </div>
|
|
|
+ <div className="grid grid-cols-2 gap-4">
|
|
|
+ <div className="col-span-2">
|
|
|
+ <label className="label">Libellé</label>
|
|
|
+ <input className="input" value={editForm.detected_label} onChange={e => setEditForm(f => ({ ...f, detected_label: e.target.value }))} />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label className="label">Fournisseur</label>
|
|
|
+ <input className="input" value={editForm.detected_supplier} onChange={e => setEditForm(f => ({ ...f, detected_supplier: e.target.value }))} placeholder="Ex: EDF" />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label className="label">Catégorie</label>
|
|
|
+ <select className="input" value={editForm.detected_category} onChange={e => setEditForm(f => ({ ...f, detected_category: e.target.value }))}>
|
|
|
+ {CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label className="label">Montant (€)</label>
|
|
|
+ <input className="input" type="number" step="0.01" min="0" value={editForm.detected_amount} onChange={e => setEditForm(f => ({ ...f, detected_amount: parseFloat(e.target.value) || '' }))} />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label className="label">Année</label>
|
|
|
+ <input className="input" type="number" min="2000" max="2099" value={editForm.year} onChange={e => setEditForm(f => ({ ...f, year: parseInt(e.target.value) || '' }))} />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label className="label">Type de charge</label>
|
|
|
+ <select className="input" value={editForm.recoverable_type} onChange={e => setEditForm(f => ({ ...f, recoverable_type: e.target.value }))}>
|
|
|
+ <option value="recoverable">Récupérable</option>
|
|
|
+ <option value="deductible">Déductible</option>
|
|
|
+ <option value="none">Non récupérable</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label className="label">Notes</label>
|
|
|
+ <input className="input" value={editForm.notes} onChange={e => setEditForm(f => ({ ...f, notes: e.target.value }))} placeholder="Optionnel" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div className="flex gap-3 pt-2">
|
|
|
+ <button onClick={() => setEditInvoice(null)} className="btn-secondary flex-1">Annuler</button>
|
|
|
+ <button onClick={handleSave} className="btn-secondary flex-1">💾 Sauvegarder</button>
|
|
|
+ <button onClick={() => handleApprove(editInvoice)} className="btn-primary flex-1">✅ Approuver</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </Modal>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|