|
@@ -19,6 +19,20 @@ const STATUS_LABELS = {
|
|
|
rejected: { label: 'Rejetée', class: 'bg-red-100 text-red-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 }) {
|
|
function Modal({ title, onClose, children }) {
|
|
|
return (
|
|
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="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4">
|
|
@@ -47,6 +61,8 @@ export default function InvoiceScanner() {
|
|
|
const [previewId, setPreviewId] = useState(null)
|
|
const [previewId, setPreviewId] = useState(null)
|
|
|
const [previewUrl, setPreviewUrl] = useState(null)
|
|
const [previewUrl, setPreviewUrl] = 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))
|
|
|
|
|
|
|
@@ -94,7 +110,7 @@ export default function InvoiceScanner() {
|
|
|
loadStats()
|
|
loadStats()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const openEdit = (inv) => {
|
|
|
|
|
|
|
+ const openEdit = async (inv) => {
|
|
|
setEditForm({
|
|
setEditForm({
|
|
|
detected_supplier: inv.detected_supplier || '',
|
|
detected_supplier: inv.detected_supplier || '',
|
|
|
detected_category: inv.detected_category || 'autre',
|
|
detected_category: inv.detected_category || 'autre',
|
|
@@ -105,6 +121,36 @@ export default function InvoiceScanner() {
|
|
|
recoverable_type: 'recoverable',
|
|
recoverable_type: 'recoverable',
|
|
|
})
|
|
})
|
|
|
setEditInvoice(inv)
|
|
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 () => {
|
|
const handleSave = async () => {
|
|
@@ -327,57 +373,75 @@ export default function InvoiceScanner() {
|
|
|
</Modal>
|
|
</Modal>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
- {/* Edit/Approve modal */}
|
|
|
|
|
|
|
+ {/* Edit/Approve modal - split layout */}
|
|
|
{editInvoice && (
|
|
{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={() => { openPreview(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) || '' }))} />
|
|
|
|
|
|
|
+ <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>
|
|
|
- <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>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 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.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>
|
|
|
- <div>
|
|
|
|
|
- <label className="label">Notes</label>
|
|
|
|
|
- <input className="input" value={editForm.notes} onChange={e => setEditForm(f => ({ ...f, notes: e.target.value }))} placeholder="Optionnel" />
|
|
|
|
|
|
|
+ <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>
|
|
</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>
|
|
</div>
|
|
|
- </Modal>
|
|
|
|
|
|
|
+ </WideModal>
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|
|
|
)
|
|
)
|