jeremy 4 mēneši atpakaļ
vecāks
revīzija
ad45d7419f

+ 1 - 1
backend/Dockerfile

@@ -7,7 +7,7 @@ RUN npm install --production
 
 COPY src ./src
 
-RUN mkdir -p data
+RUN mkdir -p data uploads
 
 EXPOSE 3001
 

+ 1 - 0
backend/package.json

@@ -12,6 +12,7 @@
     "cors": "^2.8.5",
     "express": "^4.18.2",
     "jsonwebtoken": "^9.0.2",
+    "multer": "^1.4.5-lts.1",
     "pdfkit": "^0.15.0"
   },
   "devDependencies": {

+ 17 - 0
backend/src/db.js

@@ -73,6 +73,23 @@ db.exec(`
     FOREIGN KEY (lease_id) REFERENCES leases(id),
     FOREIGN KEY (user_id) REFERENCES users(id)
   );
+
+  CREATE TABLE IF NOT EXISTS charges (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    user_id INTEGER NOT NULL,
+    property_id INTEGER NOT NULL,
+    category TEXT NOT NULL,
+    label TEXT NOT NULL,
+    amount REAL NOT NULL,
+    date TEXT NOT NULL,
+    year INTEGER NOT NULL,
+    invoice_path TEXT,
+    invoice_name TEXT,
+    notes TEXT,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    FOREIGN KEY (user_id) REFERENCES users(id),
+    FOREIGN KEY (property_id) REFERENCES properties(id)
+  );
 `);
 
 module.exports = db;

+ 198 - 0
backend/src/routes/charges.js

@@ -0,0 +1,198 @@
+const express = require('express');
+const path = require('path');
+const fs = require('fs');
+const multer = require('multer');
+const db = require('../db');
+const { authMiddleware } = require('../auth');
+
+const router = express.Router();
+router.use(authMiddleware);
+
+// Configure multer for invoice uploads
+const uploadDir = path.join(__dirname, '../../uploads');
+if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
+
+const storage = multer.diskStorage({
+  destination: (req, file, cb) => cb(null, uploadDir),
+  filename: (req, file, cb) => {
+    const ext = path.extname(file.originalname);
+    cb(null, `charge_${Date.now()}_${Math.random().toString(36).slice(2)}${ext}`);
+  }
+});
+const upload = multer({
+  storage,
+  limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
+  fileFilter: (req, file, cb) => {
+    const allowed = ['.pdf', '.jpg', '.jpeg', '.png', '.webp'];
+    if (allowed.includes(path.extname(file.originalname).toLowerCase())) cb(null, true);
+    else cb(new Error('Fichier non autorisé (PDF, JPG, PNG)'));
+  }
+});
+
+// List charges (filter by property_id, year)
+router.get('/', (req, res) => {
+  const { property_id, year } = req.query;
+  let query = `
+    SELECT c.*, p.name as property_name
+    FROM charges c
+    JOIN properties p ON c.property_id = p.id
+    WHERE c.user_id = ?
+  `;
+  const params = [req.userId];
+  if (property_id) { query += ' AND c.property_id = ?'; params.push(property_id); }
+  if (year) { query += ' AND c.year = ?'; params.push(year); }
+  query += ' ORDER BY c.date DESC';
+  res.json(db.prepare(query).all(...params));
+});
+
+// Create charge (with optional invoice file)
+router.post('/', upload.single('invoice'), (req, res) => {
+  const { property_id, category, label, amount, date, year, notes } = req.body;
+  if (!property_id || !category || !label || !amount || !date || !year)
+    return res.status(400).json({ error: 'Champs requis manquants' });
+
+  const prop = db.prepare('SELECT id FROM properties WHERE id = ? AND user_id = ?').get(property_id, req.userId);
+  if (!prop) return res.status(403).json({ error: 'Bien non autorisé' });
+
+  const invoice_path = req.file ? req.file.filename : null;
+  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);
+
+  res.status(201).json({ id: result.lastInsertRowid });
+});
+
+// Update charge (with optional new invoice)
+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;
+
+  let invoice_path = charge.invoice_path;
+  let invoice_name = charge.invoice_name;
+
+  if (req.file) {
+    // Delete old file
+    if (charge.invoice_path) {
+      const oldPath = path.join(uploadDir, charge.invoice_path);
+      if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
+    }
+    invoice_path = req.file.filename;
+    invoice_name = req.file.originalname;
+  }
+
+  db.prepare(`
+    UPDATE charges SET category=?, label=?, amount=?, date=?, year=?, invoice_path=?, invoice_name=?, notes=?
+    WHERE id=?
+  `).run(category, label, parseFloat(amount), date, parseInt(year), invoice_path, invoice_name, notes || null, req.params.id);
+
+  res.json({ success: true });
+});
+
+// Delete charge
+router.delete('/:id', (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' });
+
+  if (charge.invoice_path) {
+    const filePath = path.join(uploadDir, charge.invoice_path);
+    if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
+  }
+
+  db.prepare('DELETE FROM charges WHERE id = ?').run(req.params.id);
+  res.json({ success: true });
+});
+
+// Download invoice
+router.get('/:id/invoice', (req, res) => {
+  const charge = db.prepare('SELECT * FROM charges WHERE id = ? AND user_id = ?').get(req.params.id, req.userId);
+  if (!charge || !charge.invoice_path) return res.status(404).json({ error: 'Facture non trouvée' });
+
+  const filePath = path.join(uploadDir, charge.invoice_path);
+  if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Fichier introuvable' });
+
+  res.setHeader('Content-Disposition', `attachment; filename="${charge.invoice_name}"`);
+  res.sendFile(filePath);
+});
+
+// Annual bilan for a property
+router.get('/bilan/:property_id/:year', (req, res) => {
+  const { property_id, year } = req.params;
+
+  // Verify property ownership
+  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
+  const totalChargesRow = db.prepare(`
+    SELECT SUM(amount) as total, COUNT(*) as count
+    FROM charges WHERE property_id = ? AND year = ? AND user_id = ?
+  `).get(property_id, year, req.userId);
+
+  const totalChargesReelles = totalChargesRow?.total || 0;
+
+  // Charges by category
+  const chargesByCategory = db.prepare(`
+    SELECT category, SUM(amount) as total
+    FROM charges WHERE property_id = ? AND year = ? AND user_id = ?
+    GROUP BY category ORDER BY total DESC
+  `).all(property_id, year, req.userId);
+
+  // All leases on this property (active or that were active during the year)
+  const leases = db.prepare(`
+    SELECT l.*, t.first_name, t.last_name, t.email
+    FROM leases l
+    JOIN tenants t ON l.tenant_id = t.id
+    WHERE l.property_id = ? AND l.user_id = ?
+      AND (l.start_date <= ? AND (l.end_date IS NULL OR l.end_date >= ?))
+  `).all(property_id, req.userId, `${year}-12-31`, `${year}-01-01`);
+
+  // For each lease, get provisions perçues for the year
+  const leaseBilans = leases.map(lease => {
+    const provisions = db.prepare(`
+      SELECT SUM(charges_paid) as total
+      FROM payments
+      WHERE lease_id = ? AND period_year = ?
+    `).get(lease.id, year);
+
+    return {
+      lease_id: lease.id,
+      tenant_name: `${lease.first_name} ${lease.last_name}`,
+      tenant_email: lease.email,
+      monthly_provision: lease.charges_amount,
+      provisions_percues: provisions?.total || 0
+    };
+  });
+
+  // Calculate total provisions across all tenants for proportional split
+  const totalProvisions = leaseBilans.reduce((s, l) => s + l.provisions_percues, 0);
+
+  // Compute each tenant's share
+  const bilans = leaseBilans.map(l => {
+    const ratio = totalProvisions > 0 ? l.provisions_percues / totalProvisions : (leaseBilans.length > 0 ? 1 / leaseBilans.length : 0);
+    const quote_part_charges = totalChargesReelles * ratio;
+    const trop_percu = l.provisions_percues - quote_part_charges;
+    return {
+      ...l,
+      ratio_percent: Math.round(ratio * 100 * 100) / 100,
+      quote_part_charges: Math.round(quote_part_charges * 100) / 100,
+      trop_percu: Math.round(trop_percu * 100) / 100
+    };
+  });
+
+  res.json({
+    property: prop,
+    year: parseInt(year),
+    total_charges_reelles: Math.round(totalChargesReelles * 100) / 100,
+    charges_count: totalChargesRow?.count || 0,
+    charges_by_category: chargesByCategory,
+    total_provisions_percues: Math.round(totalProvisions * 100) / 100,
+    bilans
+  });
+});
+
+module.exports = router;

+ 4 - 1
backend/src/server.js

@@ -3,9 +3,11 @@ const cors = require('cors');
 const path = require('path');
 const fs = require('fs');
 
-// Ensure data directory exists
+// Ensure data and uploads directories exist
 const dataDir = path.join(__dirname, '../data');
 if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
+const uploadsDir = path.join(__dirname, '../uploads');
+if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
 
 const app = express();
 app.use(cors());
@@ -18,6 +20,7 @@ app.use('/api/tenants', require('./routes/tenants'));
 app.use('/api/leases', require('./routes/leases'));
 app.use('/api/payments', require('./routes/payments'));
 app.use('/api/receipts', require('./routes/receipts'));
+app.use('/api/charges', require('./routes/charges'));
 
 app.get('/api/health', (_, res) => res.json({ ok: true }));
 

+ 1 - 0
docker-compose.yml

@@ -9,6 +9,7 @@ services:
       - "3001:3001"
     volumes:
       - ./backend/data:/app/data
+      - ./backend/uploads:/app/uploads
     environment:
       - NODE_ENV=production
       - JWT_SECRET=change_this_secret_in_production

+ 2 - 0
frontend/src/App.jsx

@@ -10,6 +10,7 @@ import Tenants from './pages/Tenants'
 import Leases from './pages/Leases'
 import LeaseDetail from './pages/LeaseDetail'
 import Payments from './pages/Payments'
+import Charges from './pages/Charges'
 
 function PrivateRoute({ children }) {
   const { user, loading } = useAuth()
@@ -38,6 +39,7 @@ export default function App() {
             <Route path="leases" element={<Leases />} />
             <Route path="leases/:id" element={<LeaseDetail />} />
             <Route path="payments" element={<Payments />} />
+            <Route path="charges" element={<Charges />} />
           </Route>
         </Routes>
       </BrowserRouter>

+ 1 - 0
frontend/src/components/Layout.jsx

@@ -7,6 +7,7 @@ const navItems = [
   { to: '/tenants', label: '👤 Locataires' },
   { to: '/leases', label: '📋 Baux' },
   { to: '/payments', label: '💰 Paiements' },
+  { to: '/charges', label: '🧾 Charges' },
 ]
 
 export default function Layout() {

+ 434 - 0
frontend/src/pages/Charges.jsx

@@ -0,0 +1,434 @@
+import { useEffect, useState } from 'react'
+import api from '../api'
+import toast from 'react-hot-toast'
+
+const CATEGORIES = [
+  { value: 'eau', label: '💧 Eau' },
+  { value: 'gaz', label: '🔥 Gaz' },
+  { value: 'electricite', label: '⚡ Électricité' },
+  { value: 'entretien', label: '🔧 Entretien / Réparations' },
+  { value: 'ascenseur', label: '🛗 Ascenseur' },
+  { value: 'ordures', label: '🗑️ Ordures ménagères' },
+  { value: 'assurance', label: '🛡️ Assurance' },
+  { value: 'autre', label: '📦 Autre' },
+]
+
+const CURRENT_YEAR = new Date().getFullYear()
+const YEARS = Array.from({ length: 6 }, (_, i) => CURRENT_YEAR - i)
+
+function Modal({ title, onClose, children }) {
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
+      <div className="bg-white rounded-2xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
+        <div className="flex items-center justify-between p-6 border-b sticky top-0 bg-white z-10">
+          <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-6">{children}</div>
+      </div>
+    </div>
+  )
+}
+
+function CategoryBadge({ value }) {
+  const cat = CATEGORIES.find(c => c.value === value)
+  return <span className="inline-flex items-center gap-1 text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full font-medium">{cat?.label || value}</span>
+}
+
+export default function Charges() {
+  const [properties, setProperties] = useState([])
+  const [charges, setCharges] = useState([])
+  const [filterProperty, setFilterProperty] = useState('')
+  const [filterYear, setFilterYear] = useState(String(CURRENT_YEAR))
+  const [showModal, setShowModal] = useState(false)
+  const [showBilan, setShowBilan] = useState(false)
+  const [bilan, setBilan] = useState(null)
+  const [bilanLoading, setBilanLoading] = useState(false)
+  const [loading, setLoading] = useState(false)
+  const [editCharge, setEditCharge] = useState(null)
+
+  const emptyForm = {
+    property_id: '',
+    category: 'eau',
+    label: '',
+    amount: '',
+    date: new Date().toISOString().slice(0, 10),
+    year: String(CURRENT_YEAR),
+    notes: '',
+    invoice: null,
+  }
+  const [form, setForm] = useState(emptyForm)
+
+  const loadProperties = async () => {
+    const { data } = await api.get('/properties')
+    setProperties(data)
+    if (data.length && !filterProperty) setFilterProperty(String(data[0].id))
+  }
+
+  const loadCharges = async () => {
+    const params = new URLSearchParams()
+    if (filterProperty) params.append('property_id', filterProperty)
+    if (filterYear) params.append('year', filterYear)
+    const { data } = await api.get(`/charges?${params}`)
+    setCharges(data)
+  }
+
+  useEffect(() => { loadProperties() }, [])
+  useEffect(() => { if (filterProperty) loadCharges() }, [filterProperty, filterYear])
+
+  const openAdd = () => {
+    setEditCharge(null)
+    setForm({ ...emptyForm, property_id: filterProperty, year: filterYear })
+    setShowModal(true)
+  }
+
+  const openEdit = (c) => {
+    setEditCharge(c)
+    setForm({
+      property_id: String(c.property_id),
+      category: c.category,
+      label: c.label,
+      amount: String(c.amount),
+      date: c.date,
+      year: String(c.year),
+      notes: c.notes || '',
+      invoice: null,
+    })
+    setShowModal(true)
+  }
+
+  const handleSubmit = async e => {
+    e.preventDefault(); setLoading(true)
+    try {
+      const fd = new FormData()
+      Object.entries(form).forEach(([k, v]) => {
+        if (k === 'invoice') { if (v) fd.append('invoice', v) }
+        else fd.append(k, v)
+      })
+      if (editCharge) {
+        await api.put(`/charges/${editCharge.id}`, fd, { headers: { 'Content-Type': 'multipart/form-data' } })
+        toast.success('Charge mise à jour !')
+      } else {
+        await api.post('/charges', fd, { headers: { 'Content-Type': 'multipart/form-data' } })
+        toast.success('Charge ajoutée !')
+      }
+      setShowModal(false)
+      loadCharges()
+    } catch (err) { toast.error(err.response?.data?.error || 'Erreur') }
+    finally { setLoading(false) }
+  }
+
+  const handleDelete = async (id) => {
+    if (!confirm('Supprimer cette charge ?')) return
+    try { await api.delete(`/charges/${id}`); toast.success('Charge supprimée'); loadCharges() }
+    catch { toast.error('Erreur lors de la suppression') }
+  }
+
+  const downloadInvoice = async (charge) => {
+    try {
+      const token = localStorage.getItem('token')
+      const resp = await fetch(`/api/charges/${charge.id}/invoice`, {
+        headers: { Authorization: `Bearer ${token}` }
+      })
+      if (!resp.ok) throw new Error()
+      const blob = await resp.blob()
+      const url = window.URL.createObjectURL(blob)
+      const a = document.createElement('a')
+      a.href = url; a.download = charge.invoice_name; a.click()
+      window.URL.revokeObjectURL(url)
+    } catch { toast.error('Impossible de télécharger la facture') }
+  }
+
+  const loadBilan = async () => {
+    if (!filterProperty || !filterYear) return
+    setBilanLoading(true)
+    try {
+      const { data } = await api.get(`/charges/bilan/${filterProperty}/${filterYear}`)
+      setBilan(data)
+      setShowBilan(true)
+    } catch (err) { toast.error(err.response?.data?.error || 'Erreur bilan') }
+    finally { setBilanLoading(false) }
+  }
+
+  const fmt = v => `${Number(v).toFixed(2)} €`
+  const fmtDate = d => d ? new Date(d + 'T00:00:00').toLocaleDateString('fr-FR') : '—'
+  const totalAmount = charges.reduce((s, c) => s + c.amount, 0)
+
+  return (
+    <div>
+      <div className="flex items-center justify-between mb-6">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">🧾 Gestion des charges</h1>
+          <p className="text-gray-500 text-sm mt-1">Charges réelles par immeuble et bilan annuel</p>
+        </div>
+        <div className="flex gap-2">
+          <button onClick={loadBilan} disabled={!filterProperty || bilanLoading}
+            className="btn-secondary flex items-center gap-2">
+            {bilanLoading ? '...' : '📊 Bilan annuel'}
+          </button>
+          <button onClick={openAdd} className="btn-primary">+ Ajouter une charge</button>
+        </div>
+      </div>
+
+      {/* Filters */}
+      <div className="card mb-6 flex flex-wrap gap-4">
+        <div className="flex-1 min-w-48">
+          <label className="label">Immeuble / Bien</label>
+          <select className="input" value={filterProperty} onChange={e => setFilterProperty(e.target.value)}>
+            <option value="">Tous les biens</option>
+            {properties.map(p => <option key={p.id} value={p.id}>{p.name} — {p.address}</option>)}
+          </select>
+        </div>
+        <div className="w-36">
+          <label className="label">Année</label>
+          <select className="input" value={filterYear} onChange={e => setFilterYear(e.target.value)}>
+            {YEARS.map(y => <option key={y} value={y}>{y}</option>)}
+          </select>
+        </div>
+        {filterProperty && filterYear && (
+          <div className="flex items-end">
+            <div className="bg-blue-50 border border-blue-200 rounded-lg px-4 py-2 text-sm">
+              <span className="text-blue-500">Total charges :</span>
+              <span className="text-blue-800 font-bold ml-2">{fmt(totalAmount)}</span>
+              <span className="text-blue-400 ml-2">({charges.length} ligne{charges.length > 1 ? 's' : ''})</span>
+            </div>
+          </div>
+        )}
+      </div>
+
+      {/* Charges list */}
+      {charges.length === 0 ? (
+        <div className="card text-center py-12">
+          <p className="text-4xl mb-3">🧾</p>
+          <p className="text-gray-500">Aucune charge enregistrée pour cette période.</p>
+          <button onClick={openAdd} className="btn-primary mt-4">Ajouter une première charge</button>
+        </div>
+      ) : (
+        <div className="card overflow-hidden p-0">
+          <table className="w-full">
+            <thead className="bg-gray-50 border-b">
+              <tr>
+                <th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Date</th>
+                <th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Catégorie</th>
+                <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">Facture</th>
+                <th className="px-6 py-3"></th>
+              </tr>
+            </thead>
+            <tbody className="divide-y divide-gray-50">
+              {charges.map(c => (
+                <tr key={c.id} className="hover:bg-gray-50">
+                  <td className="px-6 py-4 text-sm text-gray-600">{fmtDate(c.date)}</td>
+                  <td className="px-6 py-4"><CategoryBadge value={c.category} /></td>
+                  <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.invoice_path ? (
+                      <button onClick={() => downloadInvoice(c)}
+                        className="text-green-600 hover:text-green-800 text-sm font-medium" title={c.invoice_name}>
+                        📎 {c.invoice_name?.length > 20 ? c.invoice_name.slice(0, 17) + '…' : c.invoice_name}
+                      </button>
+                    ) : (
+                      <span className="text-gray-300 text-xs">—</span>
+                    )}
+                  </td>
+                  <td className="px-6 py-4 text-right space-x-2 whitespace-nowrap">
+                    <button onClick={() => openEdit(c)} className="text-blue-500 hover:text-blue-700 text-sm">✏️</button>
+                    <button onClick={() => handleDelete(c.id)} className="text-gray-400 hover:text-red-600 ml-1">🗑️</button>
+                  </td>
+                </tr>
+              ))}
+            </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 className="px-6 py-3 text-right font-bold text-gray-900">{fmt(totalAmount)}</td>
+                <td colSpan={2}></td>
+              </tr>
+            </tfoot>
+          </table>
+        </div>
+      )}
+
+      {/* Add/Edit Modal */}
+      {showModal && (
+        <Modal title={editCharge ? 'Modifier la charge' : 'Ajouter une charge'} onClose={() => setShowModal(false)}>
+          <form onSubmit={handleSubmit} className="space-y-4">
+            <div>
+              <label className="label">Immeuble / Bien *</label>
+              <select className="input" required value={form.property_id}
+                onChange={e => setForm(f => ({ ...f, property_id: e.target.value }))}>
+                <option value="">— Sélectionner —</option>
+                {properties.map(p => <option key={p.id} value={p.id}>{p.name} — {p.address}</option>)}
+              </select>
+            </div>
+            <div className="grid grid-cols-2 gap-4">
+              <div>
+                <label className="label">Catégorie *</label>
+                <select className="input" value={form.category}
+                  onChange={e => setForm(f => ({ ...f, category: e.target.value }))}>
+                  {CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
+                </select>
+              </div>
+              <div>
+                <label className="label">Année *</label>
+                <select className="input" value={form.year}
+                  onChange={e => setForm(f => ({ ...f, year: e.target.value }))}>
+                  {YEARS.map(y => <option key={y} value={y}>{y}</option>)}
+                </select>
+              </div>
+            </div>
+            <div>
+              <label className="label">Libellé *</label>
+              <input className="input" required value={form.label}
+                onChange={e => setForm(f => ({ ...f, label: e.target.value }))}
+                placeholder="Ex : Facture eau T1 2024" />
+            </div>
+            <div className="grid grid-cols-2 gap-4">
+              <div>
+                <label className="label">Montant (€) *</label>
+                <input className="input" type="number" step="0.01" min="0" required value={form.amount}
+                  onChange={e => setForm(f => ({ ...f, amount: e.target.value }))} />
+              </div>
+              <div>
+                <label className="label">Date de la dépense *</label>
+                <input className="input" type="date" required value={form.date}
+                  onChange={e => setForm(f => ({ ...f, date: e.target.value }))} />
+              </div>
+            </div>
+            <div>
+              <label className="label">Notes (optionnel)</label>
+              <input className="input" value={form.notes}
+                onChange={e => setForm(f => ({ ...f, notes: e.target.value }))}
+                placeholder="Numéro de facture, prestataire…" />
+            </div>
+            <div>
+              <label className="label">Facture (PDF, JPG, PNG — max 10 Mo)</label>
+              {editCharge?.invoice_name && !form.invoice && (
+                <p className="text-xs text-green-600 mb-1">📎 Fichier actuel : {editCharge.invoice_name}</p>
+              )}
+              <input className="input" type="file" accept=".pdf,.jpg,.jpeg,.png,.webp"
+                onChange={e => setForm(f => ({ ...f, invoice: e.target.files[0] || null }))} />
+            </div>
+            <div className="flex gap-3 pt-2">
+              <button type="button" onClick={() => setShowModal(false)} className="btn-secondary flex-1">Annuler</button>
+              <button type="submit" disabled={loading} className="btn-primary flex-1">
+                {loading ? '...' : editCharge ? 'Mettre à jour' : 'Ajouter'}
+              </button>
+            </div>
+          </form>
+        </Modal>
+      )}
+
+      {/* Bilan annuel Modal */}
+      {showBilan && bilan && (
+        <Modal title={`📊 Bilan annuel ${bilan.year} — ${bilan.property?.name}`} onClose={() => setShowBilan(false)}>
+          <div className="space-y-6">
+            {/* 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-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>
+              <div className="bg-blue-50 border border-blue-100 rounded-xl p-4 text-center">
+                <p className="text-xs text-blue-500 font-medium mb-1">Provisions perçues</p>
+                <p className="text-2xl font-bold text-blue-700">{fmt(bilan.total_provisions_percues)}</p>
+                <p className="text-xs text-blue-400 mt-1">de tous les locataires</p>
+              </div>
+            </div>
+
+            {/* Solde global */}
+            {(() => {
+              const solde = bilan.total_provisions_percues - bilan.total_charges_reelles
+              const isPositive = solde >= 0
+              return (
+                <div className={`rounded-xl p-4 text-center border ${isPositive ? 'bg-green-50 border-green-200' : 'bg-orange-50 border-orange-200'}`}>
+                  <p className={`text-sm font-medium ${isPositive ? 'text-green-600' : 'text-orange-600'}`}>
+                    {isPositive ? '✅ Trop-perçu global (à restituer)' : '⚠️ Solde insuffisant (à appeler)'}
+                  </p>
+                  <p className={`text-3xl font-bold mt-1 ${isPositive ? 'text-green-700' : 'text-orange-700'}`}>
+                    {isPositive ? '+' : ''}{fmt(solde)}
+                  </p>
+                </div>
+              )
+            })()}
+
+            {/* Charges par catégorie */}
+            {bilan.charges_by_category.length > 0 && (
+              <div>
+                <h3 className="text-sm font-semibold text-gray-700 mb-2">Répartition par catégorie</h3>
+                <div className="space-y-1">
+                  {bilan.charges_by_category.map(c => {
+                    const cat = CATEGORIES.find(x => x.value === c.category)
+                    const pct = bilan.total_charges_reelles > 0 ? (c.total / bilan.total_charges_reelles * 100).toFixed(1) : 0
+                    return (
+                      <div key={c.category} className="flex items-center justify-between text-sm py-1">
+                        <span className="text-gray-600">{cat?.label || c.category}</span>
+                        <div className="flex items-center gap-3">
+                          <div className="w-24 bg-gray-100 rounded-full h-1.5">
+                            <div className="bg-blue-500 h-1.5 rounded-full" style={{ width: `${pct}%` }}></div>
+                          </div>
+                          <span className="text-gray-500 w-8 text-right text-xs">{pct}%</span>
+                          <span className="font-medium text-gray-900 w-20 text-right">{fmt(c.total)}</span>
+                        </div>
+                      </div>
+                    )
+                  })}
+                </div>
+              </div>
+            )}
+
+            {/* Par locataire */}
+            {bilan.bilans.length > 0 ? (
+              <div>
+                <h3 className="text-sm font-semibold text-gray-700 mb-3">Décompte par locataire</h3>
+                <div className="space-y-3">
+                  {bilan.bilans.map(b => {
+                    const isPos = b.trop_percu >= 0
+                    return (
+                      <div key={b.lease_id} className="border rounded-xl p-4">
+                        <div className="flex items-start justify-between mb-3">
+                          <div>
+                            <p className="font-semibold text-gray-900">{b.tenant_name}</p>
+                            {b.tenant_email && <p className="text-xs text-gray-400">{b.tenant_email}</p>}
+                          </div>
+                          <span className={`text-sm font-bold px-2 py-0.5 rounded-full ${isPos ? 'bg-green-100 text-green-700' : 'bg-orange-100 text-orange-700'}`}>
+                            {isPos ? '↩ Remboursement' : '↑ Appel de fonds'}
+                          </span>
+                        </div>
+                        <div className="grid grid-cols-3 gap-2 text-sm">
+                          <div className="bg-gray-50 rounded-lg p-2 text-center">
+                            <p className="text-xs text-gray-500">Provisions perçues</p>
+                            <p className="font-semibold text-gray-800">{fmt(b.provisions_percues)}</p>
+                          </div>
+                          <div className="bg-gray-50 rounded-lg p-2 text-center">
+                            <p className="text-xs text-gray-500">Quote-part réelle ({b.ratio_percent}%)</p>
+                            <p className="font-semibold text-gray-800">{fmt(b.quote_part_charges)}</p>
+                          </div>
+                          <div className={`rounded-lg p-2 text-center ${isPos ? 'bg-green-50' : 'bg-orange-50'}`}>
+                            <p className={`text-xs font-medium ${isPos ? 'text-green-600' : 'text-orange-600'}`}>
+                              {isPos ? 'Trop-perçu' : 'Solde dû'}
+                            </p>
+                            <p className={`font-bold text-lg ${isPos ? 'text-green-700' : 'text-orange-700'}`}>
+                              {fmt(Math.abs(b.trop_percu))}
+                            </p>
+                          </div>
+                        </div>
+                      </div>
+                    )
+                  })}
+                </div>
+              </div>
+            ) : (
+              <p className="text-center text-gray-400 text-sm py-4">Aucun locataire trouvé pour cette période.</p>
+            )}
+          </div>
+        </Modal>
+      )}
+    </div>
+  )
+}