jeremy há 4 meses atrás
pai
commit
fc08aab34c
3 ficheiros alterados com 68 adições e 15 exclusões
  1. 3 0
      backend/src/db.js
  2. 29 13
      backend/src/routes/charges.js
  3. 36 2
      frontend/src/pages/Charges.jsx

+ 3 - 0
backend/src/db.js

@@ -113,4 +113,7 @@ db.exec(`
   )
 `);
 
+// Add recoverable column to charges if missing
+try { db.exec('ALTER TABLE charges ADD COLUMN recoverable INTEGER NOT NULL DEFAULT 1'); } catch {}
+
 module.exports = db;

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

@@ -49,7 +49,7 @@ router.get('/', (req, res) => {
 
 // Create charge (with optional invoice file)
 router.post('/', upload.single('invoice'), (req, res) => {
-  const { property_id, category, label, amount, date, year, notes } = req.body;
+  const { property_id, category, label, amount, date, year, notes, recoverable } = req.body;
   if (!property_id || !category || !label || !amount || !date || !year)
     return res.status(400).json({ error: 'Champs requis manquants' });
 
@@ -60,9 +60,9 @@ router.post('/', upload.single('invoice'), (req, res) => {
   const invoice_name = req.file ? req.file.originalname : null;
 
   const result = db.prepare(`
-    INSERT INTO charges (user_id, property_id, category, label, amount, date, year, invoice_path, invoice_name, notes)
-    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
-  `).run(req.userId, property_id, category, label, parseFloat(amount), date, parseInt(year), invoice_path, invoice_name, notes || null);
+    INSERT INTO charges (user_id, property_id, category, label, amount, date, year, invoice_path, invoice_name, notes, recoverable)
+    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+  `).run(req.userId, property_id, category, label, parseFloat(amount), date, parseInt(year), invoice_path, invoice_name, notes || null, recoverable === 'false' || recoverable === '0' ? 0 : 1);
 
   res.status(201).json({ id: result.lastInsertRowid });
 });
@@ -72,7 +72,7 @@ router.put('/:id', upload.single('invoice'), (req, res) => {
   const charge = db.prepare('SELECT * FROM charges WHERE id = ? AND user_id = ?').get(req.params.id, req.userId);
   if (!charge) return res.status(404).json({ error: 'Charge non trouvée' });
 
-  const { category, label, amount, date, year, notes } = req.body;
+  const { category, label, amount, date, year, notes, recoverable } = req.body;
 
   let invoice_path = charge.invoice_path;
   let invoice_name = charge.invoice_name;
@@ -88,9 +88,9 @@ router.put('/:id', upload.single('invoice'), (req, res) => {
   }
 
   db.prepare(`
-    UPDATE charges SET category=?, label=?, amount=?, date=?, year=?, invoice_path=?, invoice_name=?, notes=?
+    UPDATE charges SET category=?, label=?, amount=?, date=?, year=?, invoice_path=?, invoice_name=?, notes=?, recoverable=?
     WHERE id=?
-  `).run(category, label, parseFloat(amount), date, parseInt(year), invoice_path, invoice_name, notes || null, req.params.id);
+  `).run(category, label, parseFloat(amount), date, parseInt(year), invoice_path, invoice_name, notes || null, recoverable === 'false' || recoverable === '0' ? 0 : 1, req.params.id);
 
   res.json({ success: true });
 });
@@ -129,18 +129,32 @@ router.get('/bilan/:property_id/:year', (req, res) => {
   const prop = db.prepare('SELECT * FROM properties WHERE id = ? AND user_id = ?').get(property_id, req.userId);
   if (!prop) return res.status(403).json({ error: 'Bien non autorisé' });
 
-  // Total real charges for this property/year
+  // Total recoverable charges for this property/year (for tenant billing)
   const totalChargesRow = db.prepare(`
     SELECT SUM(amount) as total, COUNT(*) as count
-    FROM charges WHERE property_id = ? AND year = ? AND user_id = ?
+    FROM charges WHERE property_id = ? AND year = ? AND user_id = ? AND recoverable = 1
+  `).get(property_id, year, req.userId);
+
+  // Total non-recoverable (landlord's own charges, for info)
+  const totalNonRecovRow = db.prepare(`
+    SELECT SUM(amount) as total, COUNT(*) as count
+    FROM charges WHERE property_id = ? AND year = ? AND user_id = ? AND recoverable = 0
   `).get(property_id, year, req.userId);
 
   const totalChargesReelles = totalChargesRow?.total || 0;
+  const totalNonRecoverable = totalNonRecovRow?.total || 0;
 
-  // Charges by category
+  // Charges by category (recoverable only)
   const chargesByCategory = db.prepare(`
     SELECT category, SUM(amount) as total
-    FROM charges WHERE property_id = ? AND year = ? AND user_id = ?
+    FROM charges WHERE property_id = ? AND year = ? AND user_id = ? AND recoverable = 1
+    GROUP BY category ORDER BY total DESC
+  `).all(property_id, year, req.userId);
+
+  // Non-recoverable by category (for info)
+  const chargesNonRecovByCategory = db.prepare(`
+    SELECT category, SUM(amount) as total
+    FROM charges WHERE property_id = ? AND year = ? AND user_id = ? AND recoverable = 0
     GROUP BY category ORDER BY total DESC
   `).all(property_id, year, req.userId);
 
@@ -190,8 +204,10 @@ router.get('/bilan/:property_id/:year', (req, res) => {
     property: prop,
     year: parseInt(year),
     total_charges_reelles: Math.round(totalChargesReelles * 100) / 100,
+    total_non_recoverable: Math.round(totalNonRecoverable * 100) / 100,
     charges_count: totalChargesRow?.count || 0,
     charges_by_category: chargesByCategory,
+    charges_non_recov_by_category: chargesNonRecovByCategory,
     total_provisions_percues: Math.round(totalProvisions * 100) / 100,
     bilans
   });
@@ -209,7 +225,7 @@ router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
 
   const totalChargesRow = db.prepare(`
     SELECT SUM(amount) as total, COUNT(*) as count FROM charges
-    WHERE property_id = ? AND year = ? AND user_id = ?
+    WHERE property_id = ? AND year = ? AND user_id = ? AND recoverable = 1
   `).get(property_id, year, req.userId);
   const totalChargesReelles = totalChargesRow?.total || 0;
 
@@ -220,7 +236,7 @@ router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
 
   const chargesByCategory = db.prepare(`
     SELECT category, SUM(amount) as total FROM charges
-    WHERE property_id = ? AND year = ? AND user_id = ?
+    WHERE property_id = ? AND year = ? AND user_id = ? AND recoverable = 1
     GROUP BY category ORDER BY total DESC
   `).all(property_id, year, req.userId);
 

+ 36 - 2
frontend/src/pages/Charges.jsx

@@ -59,6 +59,7 @@ export default function Charges() {
     year: String(CURRENT_YEAR),
     notes: '',
     invoice: null,
+    recoverable: true,
   }
   const [form, setForm] = useState(emptyForm)
 
@@ -96,6 +97,7 @@ export default function Charges() {
       year: String(c.year),
       notes: c.notes || '',
       invoice: null,
+      recoverable: c.recoverable !== 0,
     })
     setShowModal(true)
   }
@@ -106,6 +108,7 @@ export default function Charges() {
       const fd = new FormData()
       Object.entries(form).forEach(([k, v]) => {
         if (k === 'invoice') { if (v) fd.append('invoice', v) }
+        else if (k === 'recoverable') fd.append(k, v ? '1' : '0')
         else fd.append(k, v)
       })
       if (editCharge) {
@@ -372,6 +375,7 @@ export default function Charges() {
                 <th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Libellé</th>
                 <th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Immeuble</th>
                 <th className="text-right px-6 py-3 text-sm font-semibold text-gray-600">Montant</th>
+                <th className="text-center px-6 py-3 text-sm font-semibold text-gray-600">Récup.</th>
                 <th className="text-center px-6 py-3 text-sm font-semibold text-gray-600">Facture</th>
                 <th className="px-6 py-3"></th>
               </tr>
@@ -384,6 +388,12 @@ export default function Charges() {
                   <td className="px-6 py-4 text-gray-900 font-medium">{c.label}</td>
                   <td className="px-6 py-4 text-sm text-gray-500">{c.property_name}</td>
                   <td className="px-6 py-4 text-right font-semibold text-gray-900">{fmt(c.amount)}</td>
+                  <td className="px-6 py-4 text-center">
+                    {c.recoverable !== 0
+                      ? <span className="text-xs bg-green-100 text-green-700 font-semibold px-2 py-0.5 rounded-full">✓ Oui</span>
+                      : <span className="text-xs bg-gray-100 text-gray-500 font-semibold px-2 py-0.5 rounded-full">✗ Non</span>
+                    }
+                  </td>
                   <td className="px-6 py-4 text-center">
                     {c.invoice_path ? (
                       <button onClick={() => downloadInvoice(c)}
@@ -403,7 +413,7 @@ export default function Charges() {
             </tbody>
             <tfoot className="bg-gray-50 border-t-2 border-gray-200">
               <tr>
-                <td colSpan={4} className="px-6 py-3 text-sm font-semibold text-gray-700">Total</td>
+                <td colSpan={5} className="px-6 py-3 text-sm font-semibold text-gray-700">Total</td>
                 <td className="px-6 py-3 text-right font-bold text-gray-900">{fmt(totalAmount)}</td>
                 <td colSpan={2}></td>
               </tr>
@@ -464,6 +474,22 @@ export default function Charges() {
                 onChange={e => setForm(f => ({ ...f, notes: e.target.value }))}
                 placeholder="Numéro de facture, prestataire…" />
             </div>
+            <div>
+              <label className="flex items-center gap-3 cursor-pointer select-none">
+                <input
+                  type="checkbox"
+                  className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
+                  checked={form.recoverable}
+                  onChange={e => setForm(f => ({ ...f, recoverable: e.target.checked }))}
+                />
+                <span className="text-sm font-medium text-gray-700">
+                  Charge récupérable auprès du locataire
+                </span>
+              </label>
+              <p className="text-xs text-gray-400 mt-1 ml-7">
+                Si cochée, cette charge sera incluse dans le bilan de régularisation annuel.
+              </p>
+            </div>
             <div>
               <label className="label">Facture (PDF, JPG, PNG — max 10 Mo)</label>
               {editCharge?.invoice_name && !form.invoice && (
@@ -499,7 +525,7 @@ export default function Charges() {
             {/* Summary cards */}
             <div className="grid grid-cols-2 gap-3">
               <div className="bg-red-50 border border-red-100 rounded-xl p-4 text-center">
-                <p className="text-xs text-red-500 font-medium mb-1">Charges réelles</p>
+                <p className="text-xs text-red-500 font-medium mb-1">Charges récupérables</p>
                 <p className="text-2xl font-bold text-red-700">{fmt(bilan.total_charges_reelles)}</p>
                 <p className="text-xs text-red-400 mt-1">{bilan.charges_count} facture{bilan.charges_count > 1 ? 's' : ''}</p>
               </div>
@@ -510,6 +536,14 @@ export default function Charges() {
               </div>
             </div>
 
+            {/* Charges non récupérables (info) */}
+            {bilan.total_non_recoverable > 0 && (
+              <div className="bg-gray-50 border border-gray-200 rounded-xl p-3 flex items-center justify-between text-sm">
+                <span className="text-gray-500">🔒 Charges non récupérables (à votre charge)</span>
+                <span className="font-semibold text-gray-700">{fmt(bilan.total_non_recoverable)}</span>
+              </div>
+            )}
+
             {/* Solde global */}
             {(() => {
               const solde = bilan.total_provisions_percues - bilan.total_charges_reelles