Browse Source

modifications

jeremy 4 months ago
parent
commit
cf0b29270f

+ 13 - 5
backend/src/routes/charges.js

@@ -150,8 +150,8 @@ router.put('/:id', upload.single('invoice'), async (req, res) => {
   let invoice_name = charge.invoice_name;
 
   if (req.file) {
-    // Delete old file
-    if (charge.invoice_path) {
+    // Only delete old file if it's an uploaded file (relative path), not a scan link
+    if (charge.invoice_path && !path.isAbsolute(charge.invoice_path)) {
       const oldPath = path.join(uploadDir, charge.invoice_path);
       if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
     }
@@ -169,12 +169,20 @@ router.put('/:id', upload.single('invoice'), async (req, res) => {
   res.json({ success: true });
 });
 
+// Resolve invoice file path: absolute paths (scanned) or relative paths (uploaded)
+function resolveInvoicePath(invoicePath) {
+  if (!invoicePath) return null;
+  if (path.isAbsolute(invoicePath)) return invoicePath;
+  return path.join(uploadDir, invoicePath);
+}
+
 // Delete charge
 router.delete('/:id', async (req, res) => {
   const charge = (await pool.query('SELECT * FROM charges WHERE id = $1 AND user_id = $2', [req.params.id, req.userId])).rows[0];
   if (!charge) return res.status(404).json({ error: 'Charge non trouvée' });
 
-  if (charge.invoice_path) {
+  // Only delete uploaded files (relative paths), not linked scan files (absolute paths)
+  if (charge.invoice_path && !path.isAbsolute(charge.invoice_path)) {
     const filePath = path.join(uploadDir, charge.invoice_path);
     if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
   }
@@ -188,7 +196,7 @@ router.get('/:id/invoice', async (req, res) => {
   const charge = (await pool.query('SELECT * FROM charges WHERE id = $1 AND user_id = $2', [req.params.id, req.userId])).rows[0];
   if (!charge || !charge.invoice_path) return res.status(404).json({ error: 'Facture non trouvée' });
 
-  const filePath = path.join(uploadDir, charge.invoice_path);
+  const filePath = resolveInvoicePath(charge.invoice_path);
   if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Fichier introuvable' });
 
   res.setHeader('Content-Disposition', `attachment; filename="${charge.invoice_name}"`);
@@ -535,7 +543,7 @@ router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
 
     for (const charge of chargesList) {
       if (!charge.invoice_path) continue;
-      const filePath = path.join(uploadDir, charge.invoice_path);
+      const filePath = resolveInvoicePath(charge.invoice_path);
       if (!fs.existsSync(filePath)) continue;
       const ext = path.extname(charge.invoice_path).toLowerCase();
       const fileBytes = fs.readFileSync(filePath);

+ 2 - 15
backend/src/routes/scan.js

@@ -97,21 +97,8 @@ router.post('/invoices/:id/approve', async (req, res) => {
     return res.status(400).json({ error: 'Veuillez renseigner la catégorie, le montant et le libellé avant d\'approuver' });
   }
 
-  // Copy file to uploads directory
-  const ext = path.extname(inv.file_path).toLowerCase();
-  const newFilename = `charge_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`;
-  const destPath = path.join(uploadsDir, newFilename);
-
-  try {
-    fs.copyFileSync(inv.file_path, destPath);
-  } catch (err) {
-    return res.status(500).json({ error: 'Erreur lors de la copie du fichier' });
-  }
-
-  // Determine recoverable_type from request or default
+  // Create charge entry - link to original file in scan directory (no copy)
   const recoverableType = req.body.recoverable_type || 'recoverable';
-
-  // Create charge entry
   const chargeDate = inv.year ? `${inv.year}-01-01` : new Date().toISOString().slice(0, 10);
   const chargeResult = await pool.query(
     `INSERT INTO charges (user_id, property_id, category, label, amount, date, year, invoice_path, invoice_name, notes, recoverable, recoverable_type)
@@ -119,7 +106,7 @@ router.post('/invoices/:id/approve', async (req, res) => {
     [
       req.userId, inv.property_id, inv.detected_category, inv.detected_label,
       inv.detected_amount, chargeDate, inv.year || new Date().getFullYear(),
-      newFilename, path.basename(inv.file_path),
+      inv.file_path, path.basename(inv.file_path),
       inv.notes || `Import automatique - ${inv.detected_supplier || 'Fournisseur inconnu'}`,
       recoverableType === 'recoverable' ? 1 : 0,
       recoverableType,

+ 1 - 1
backend/src/services/scanner.js

@@ -4,7 +4,7 @@ const crypto = require('crypto');
 const pool = require('../db');
 const { analyzeFile } = require('./invoiceDetector');
 
-const SUPPORTED_EXTENSIONS = ['.pdf', '.jpg', '.jpeg', '.png', '.webp'];
+const SUPPORTED_EXTENSIONS = ['.pdf'];
 
 function computeFileHash(filePath) {
   const buffer = fs.readFileSync(filePath);

+ 110 - 46
frontend/src/pages/InvoiceScanner.jsx

@@ -19,6 +19,20 @@ const STATUS_LABELS = {
   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">&times;</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">
@@ -47,6 +61,8 @@ export default function InvoiceScanner() {
   const [previewId, setPreviewId] = useState(null)
   const [previewUrl, setPreviewUrl] = useState(null)
 
+  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))
 
@@ -94,7 +110,7 @@ export default function InvoiceScanner() {
     loadStats()
   }
 
-  const openEdit = (inv) => {
+  const openEdit = async (inv) => {
     setEditForm({
       detected_supplier: inv.detected_supplier || '',
       detected_category: inv.detected_category || 'autre',
@@ -105,6 +121,36 @@ export default function InvoiceScanner() {
       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 () => {
@@ -327,57 +373,75 @@ export default function InvoiceScanner() {
         </Modal>
       )}
 
-      {/* Edit/Approve modal */}
+      {/* Edit/Approve modal - split layout */}
       {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>
-                <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>
-                <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 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>
+        </WideModal>
       )}
     </div>
   )