| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510 |
- 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 (
- <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-5xl 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>
- )
- }
- 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 [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 (
- <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>
- <input className="input w-auto" type="text" value={filterText} onChange={e => setFilterText(e.target.value)} placeholder="🔍 Filtrer par nom de fichier..." />
- </div>
- {/* Selection toolbar */}
- {selected.size > 0 && (
- <div className="flex items-center gap-3 mb-4 p-3 bg-blue-50 rounded-lg">
- <span className="text-sm font-medium text-blue-800">{selected.size} sélectionnée(s)</span>
- <button onClick={handleBulkReject} className="text-sm text-red-600 hover:text-red-800 font-medium">❌ Rejeter</button>
- <button onClick={handleBulkDelete} className="text-sm text-red-600 hover:text-red-800 font-medium">🗑️ Supprimer</button>
- <button onClick={() => setSelected(new Set())} className="text-sm text-gray-500 hover:text-gray-700 ml-auto">Désélectionner</button>
- </div>
- )}
- {/* Invoice list */}
- {filteredInvoices.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">
- {/* Select all header */}
- <div className="flex items-center gap-3 px-1">
- <input type="checkbox" checked={selected.size === filteredInvoices.length && filteredInvoices.length > 0}
- onChange={toggleSelectAll} className="rounded border-gray-300 text-blue-600" />
- <span className="text-xs text-gray-500">Tout sélectionner ({filteredInvoices.length})</span>
- </div>
- {filteredInvoices.map(inv => (
- <div key={inv.id} className={`card hover:shadow-md transition-shadow ${selected.has(inv.id) ? 'ring-2 ring-blue-300' : ''}`}>
- <div className="flex flex-col sm:flex-row sm:items-center gap-3">
- <input type="checkbox" checked={selected.has(inv.id)} onChange={() => toggleSelect(inv.id)}
- className="rounded border-gray-300 text-blue-600 shrink-0 mt-1 sm:mt-0" />
- <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={() => openPreview(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 && previewUrl && (
- <Modal title="Prévisualisation" onClose={closePreview}>
- <iframe
- src={previewUrl}
- className="w-full h-[70vh] border rounded-lg"
- title="Aperçu facture"
- />
- </Modal>
- )}
- {/* Edit/Approve modal - split layout */}
- {editInvoice && (
- <WideModal title="Vérifier et approuver la facture" onClose={closeEdit}>
- <div className="flex flex-col md:flex-row gap-6">
- {/* Left: PDF preview + download */}
- <div className="md:w-1/2 flex flex-col gap-3">
- {editPreviewUrl ? (
- <iframe
- src={editPreviewUrl}
- className="w-full h-[65vh] border rounded-lg bg-gray-50"
- title="Aperçu facture"
- />
- ) : (
- <div className="w-full h-[65vh] border rounded-lg bg-gray-50 flex items-center justify-center text-gray-400">
- Chargement de l'aperçu...
- </div>
- )}
- <div className="flex items-center gap-3 text-xs text-gray-500">
- <p className="truncate flex-1" title={editInvoice.file_path}>📄 {editInvoice.file_path.split('/').slice(-2).join('/')}</p>
- <button onClick={() => handleDownload(editInvoice)} className="text-blue-600 hover:text-blue-800 font-medium whitespace-nowrap">⬇️ Télécharger</button>
- </div>
- </div>
- {/* Right: form */}
- <div className="md:w-1/2 space-y-4">
- <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.icon} {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={closeEdit} 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>
- </div>
- </WideModal>
- )}
- </div>
- )
- }
|