|
@@ -2,17 +2,6 @@ import { useEffect, useState } from 'react'
|
|
|
import api from '../api'
|
|
import api from '../api'
|
|
|
import toast from 'react-hot-toast'
|
|
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 = {
|
|
const STATUS_LABELS = {
|
|
|
pending: { label: 'En attente', class: 'bg-yellow-100 text-yellow-800' },
|
|
pending: { label: 'En attente', class: 'bg-yellow-100 text-yellow-800' },
|
|
|
approved: { label: 'Approuvée', class: 'bg-green-100 text-green-800' },
|
|
approved: { label: 'Approuvée', class: 'bg-green-100 text-green-800' },
|
|
@@ -60,11 +49,15 @@ export default function InvoiceScanner() {
|
|
|
const [editForm, setEditForm] = useState({})
|
|
const [editForm, setEditForm] = useState({})
|
|
|
const [previewId, setPreviewId] = useState(null)
|
|
const [previewId, setPreviewId] = useState(null)
|
|
|
const [previewUrl, setPreviewUrl] = 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 [editPreviewUrl, setEditPreviewUrl] = useState(null)
|
|
|
|
|
|
|
|
const loadProperties = () => api.get('/properties').then(r => setProperties(r.data))
|
|
const loadProperties = () => api.get('/properties').then(r => setProperties(r.data))
|
|
|
const loadStats = () => api.get('/scan/stats').then(r => setStats(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 loadInvoices = () => {
|
|
|
const params = new URLSearchParams()
|
|
const params = new URLSearchParams()
|
|
@@ -74,8 +67,8 @@ export default function InvoiceScanner() {
|
|
|
api.get(`/scan/invoices?${params}`).then(r => setInvoices(r.data))
|
|
api.get(`/scan/invoices?${params}`).then(r => setInvoices(r.data))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- useEffect(() => { loadProperties(); loadStats() }, [])
|
|
|
|
|
- useEffect(() => { loadInvoices() }, [selectedProperty, filterStatus, filterYear])
|
|
|
|
|
|
|
+ useEffect(() => { loadProperties(); loadStats(); loadCategories() }, [])
|
|
|
|
|
+ useEffect(() => { loadInvoices(); setSelected(new Set()) }, [selectedProperty, filterStatus, filterYear])
|
|
|
|
|
|
|
|
const propertiesWithScan = properties.filter(p => p.scan_directory)
|
|
const propertiesWithScan = properties.filter(p => p.scan_directory)
|
|
|
|
|
|
|
@@ -229,6 +222,56 @@ export default function InvoiceScanner() {
|
|
|
const years = [...new Set(invoices.map(i => i.year).filter(Boolean))].sort((a, b) => b - a)
|
|
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()]
|
|
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 (
|
|
return (
|
|
|
<div>
|
|
<div>
|
|
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-8 gap-4">
|
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-8 gap-4">
|
|
@@ -315,18 +358,37 @@ export default function InvoiceScanner() {
|
|
|
<option value="">Toutes les années</option>
|
|
<option value="">Toutes les années</option>
|
|
|
{allYears.map(y => <option key={y} value={y}>{y}</option>)}
|
|
{allYears.map(y => <option key={y} value={y}>{y}</option>)}
|
|
|
</select>
|
|
</select>
|
|
|
|
|
+ <input className="input w-auto" type="text" value={filterText} onChange={e => setFilterText(e.target.value)} placeholder="🔍 Filtrer par nom de fichier..." />
|
|
|
</div>
|
|
</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 */}
|
|
{/* Invoice list */}
|
|
|
- {invoices.length === 0 ? (
|
|
|
|
|
|
|
+ {filteredInvoices.length === 0 ? (
|
|
|
<div className="card text-center py-12">
|
|
<div className="card text-center py-12">
|
|
|
<p className="text-gray-400">Aucune facture scannée pour les filtres sélectionnés</p>
|
|
<p className="text-gray-400">Aucune facture scannée pour les filtres sélectionnés</p>
|
|
|
</div>
|
|
</div>
|
|
|
) : (
|
|
) : (
|
|
|
<div className="space-y-3">
|
|
<div className="space-y-3">
|
|
|
- {invoices.map(inv => (
|
|
|
|
|
- <div key={inv.id} className="card hover:shadow-md transition-shadow">
|
|
|
|
|
|
|
+ {/* 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">
|
|
<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-1 min-w-0">
|
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<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}`}>
|
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_LABELS[inv.status]?.class}`}>
|
|
@@ -338,7 +400,7 @@ export default function InvoiceScanner() {
|
|
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-500">
|
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-500">
|
|
|
<span>🏠 {inv.property_name}</span>
|
|
<span>🏠 {inv.property_name}</span>
|
|
|
{inv.detected_supplier && <span>🏢 {inv.detected_supplier}</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_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>}
|
|
{inv.detected_amount != null && <span className="font-medium text-gray-700">💰 {inv.detected_amount.toFixed(2)} €</span>}
|
|
|
</div>
|
|
</div>
|
|
|
<p className="text-xs text-gray-400 mt-1 truncate" title={inv.file_path}>{inv.file_path.split('/').slice(-2).join('/')}</p>
|
|
<p className="text-xs text-gray-400 mt-1 truncate" title={inv.file_path}>{inv.file_path.split('/').slice(-2).join('/')}</p>
|
|
@@ -410,7 +472,7 @@ export default function InvoiceScanner() {
|
|
|
<div>
|
|
<div>
|
|
|
<label className="label">Catégorie</label>
|
|
<label className="label">Catégorie</label>
|
|
|
<select className="input" value={editForm.detected_category} onChange={e => setEditForm(f => ({ ...f, detected_category: e.target.value }))}>
|
|
<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>)}
|
|
|
|
|
|
|
+ {categories.map(c => <option key={c.value} value={c.value}>{c.icon} {c.label}</option>)}
|
|
|
</select>
|
|
</select>
|
|
|
</div>
|
|
</div>
|
|
|
<div>
|
|
<div>
|