jeremy преди 4 месеца
родител
ревизия
0125adc117

+ 1 - 0
backend/package.json

@@ -13,6 +13,7 @@
     "express": "^4.18.2",
     "jsonwebtoken": "^9.0.2",
     "multer": "^1.4.5-lts.1",
+    "pdf-lib": "^1.17.1",
     "pdfkit": "^0.15.0"
   },
   "devDependencies": {

+ 5 - 0
backend/src/db.js

@@ -92,4 +92,9 @@ db.exec(`
   );
 `);
 
+// Add profile columns to users if they don't exist yet
+['first_name', 'last_name', 'address', 'phone'].forEach(col => {
+  try { db.exec(`ALTER TABLE users ADD COLUMN ${col} TEXT`); } catch {}
+});
+
 module.exports = db;

+ 11 - 1
backend/src/routes/auth.js

@@ -39,7 +39,17 @@ router.post('/login', async (req, res) => {
 });
 
 router.get('/me', require('../auth').authMiddleware, (req, res) => {
-  const user = db.prepare('SELECT id, email, name FROM users WHERE id = ?').get(req.userId);
+  const user = db.prepare('SELECT id, email, name, first_name, last_name, address, phone FROM users WHERE id = ?').get(req.userId);
+  res.json(user);
+});
+
+router.put('/profile', require('../auth').authMiddleware, (req, res) => {
+  const { first_name, last_name, address, phone, email, name } = req.body;
+  db.prepare(`
+    UPDATE users SET first_name=?, last_name=?, address=?, phone=?, email=?, name=?
+    WHERE id=?
+  `).run(first_name || null, last_name || null, address || null, phone || null, email, name, req.userId);
+  const user = db.prepare('SELECT id, email, name, first_name, last_name, address, phone FROM users WHERE id = ?').get(req.userId);
   res.json(user);
 });
 

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

@@ -2,6 +2,8 @@ const express = require('express');
 const path = require('path');
 const fs = require('fs');
 const multer = require('multer');
+const PDFDocument = require('pdfkit');
+const { PDFDocument: LibPDFDocument, rgb, StandardFonts } = require('pdf-lib');
 const db = require('../db');
 const { authMiddleware } = require('../auth');
 
@@ -195,4 +197,237 @@ router.get('/bilan/:property_id/:year', (req, res) => {
   });
 });
 
+// Export PDF bilan + invoices
+router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
+  const { property_id, year } = req.params;
+
+  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é' });
+
+  const owner = db.prepare('SELECT name, first_name, last_name, email, address, phone FROM users WHERE id = ?').get(req.userId);
+  const ownerFullName = (owner.first_name && owner.last_name) ? `${owner.first_name} ${owner.last_name}` : owner.name;
+
+  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;
+
+  const chargesList = db.prepare(`
+    SELECT * FROM charges WHERE property_id = ? AND year = ? AND user_id = ?
+    ORDER BY date ASC
+  `).all(property_id, year, req.userId);
+
+  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);
+
+  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`);
+
+  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,
+      provisions_percues: provisions?.total || 0
+    };
+  });
+  const totalProvisions = leaseBilans.reduce((s, l) => s + l.provisions_percues, 0);
+  const bilans = leaseBilans.map(l => {
+    const ratio = totalProvisions > 0 ? l.provisions_percues / totalProvisions : (leaseBilans.length > 0 ? 1 / leaseBilans.length : 0);
+    const quote_part = totalChargesReelles * ratio;
+    const trop_percu = l.provisions_percues - quote_part;
+    return { ...l, ratio_percent: Math.round(ratio * 100 * 100) / 100, quote_part_charges: Math.round(quote_part * 100) / 100, trop_percu: Math.round(trop_percu * 100) / 100 };
+  });
+
+  const catLabels = { eau: 'Eau', gaz: 'Gaz', electricite: 'Électricité', entretien: 'Entretien / Réparations', ascenseur: 'Ascenseur', ordures: 'Ordures ménagères', assurance: 'Assurance', autre: 'Autre' };
+  const fmtEur = v => `${Number(v).toFixed(2)} EUR`;
+  const fmtDate = d => d ? new Date(d + 'T00:00:00').toLocaleDateString('fr-FR') : '—';
+  const solde = totalProvisions - totalChargesReelles;
+
+  try {
+    // --- Step 1: Generate bilan PDF with pdfkit ---
+    const bilanBuf = await new Promise((resolve, reject) => {
+      const doc = new PDFDocument({ margin: 50, size: 'A4' });
+      const chunks = [];
+      doc.on('data', c => chunks.push(c));
+      doc.on('end', () => resolve(Buffer.concat(chunks)));
+      doc.on('error', reject);
+
+      const W = doc.page.width - 100;
+      const blue = '#2563eb'; const red = '#dc2626'; const green = '#15803d'; const orange = '#c2410c'; const gray = '#6b7280';
+
+      // Header
+      doc.fontSize(20).fillColor('#111827').text(`Bilan annuel de charges ${year}`, { align: 'left' });
+      doc.fontSize(11).fillColor(gray).text(`${prop.name} — ${prop.address || ''}`, { align: 'left' });
+      doc.moveDown(0.5);
+      doc.fontSize(10).fillColor(gray).text(`Document généré le ${new Date().toLocaleDateString('fr-FR')}`, { align: 'right' });
+      doc.moveDown(0.8);
+
+      // Bailleur info
+      doc.moveTo(50, doc.y).lineTo(50 + W, doc.y).strokeColor('#e5e7eb').lineWidth(1).stroke();
+      doc.moveDown(0.5);
+      doc.fontSize(9).fillColor(gray).text('BAILLEUR', { continued: false });
+      doc.fontSize(10).fillColor('#111827').font('Helvetica-Bold').text(ownerFullName);
+      doc.font('Helvetica');
+      if (owner.address) doc.fontSize(10).fillColor('#374151').text(owner.address);
+      doc.fontSize(10).fillColor('#374151').text(owner.email || '');
+      if (owner.phone) doc.fontSize(10).fillColor('#374151').text(owner.phone);
+      doc.moveDown(0.5);
+      doc.moveTo(50, doc.y).lineTo(50 + W, doc.y).strokeColor('#e5e7eb').lineWidth(1).stroke();
+      doc.moveDown(1);
+
+      // Summary boxes
+      doc.fontSize(11).fillColor('#111827').text('Synthèse', { underline: true });
+      doc.moveDown(0.5);
+      const bx = 50; const bw = (W - 20) / 3;
+      [[`Charges réelles`, fmtEur(totalChargesReelles), `${totalChargesRow?.count || 0} facture(s)`, red],
+       [`Provisions perçues`, fmtEur(totalProvisions), 'de tous les locataires', blue],
+       [solde >= 0 ? 'Trop-perçu global' : 'Solde insuffisant', `${solde >= 0 ? '+' : '-'}${fmtEur(Math.abs(solde))}`, solde >= 0 ? 'à restituer' : 'à appeler', solde >= 0 ? green : orange]
+      ].forEach(([label, val, note, color], i) => {
+        const x = bx + i * (bw + 10); const y = doc.y;
+        doc.rect(x, y, bw, 60).stroke('#e5e7eb');
+        doc.fontSize(9).fillColor(gray).text(label, x + 8, y + 8, { width: bw - 16 });
+        doc.fontSize(14).fillColor(color).text(val, x + 8, y + 22, { width: bw - 16 });
+        doc.fontSize(8).fillColor(gray).text(note, x + 8, y + 42, { width: bw - 16 });
+      });
+      doc.moveDown(4.5);
+
+      // Charges by category
+      if (chargesByCategory.length > 0) {
+        doc.fontSize(11).fillColor('#111827').text('Répartition par catégorie', { underline: true });
+        doc.moveDown(0.5);
+        const colWidths = [220, 100, 80];
+        const headers = ['Catégorie', 'Montant', '%'];
+        let tx = 50; let ty = doc.y;
+        doc.fontSize(9).fillColor(gray);
+        headers.forEach((h, i) => { doc.text(h, tx, ty, { width: colWidths[i] }); tx += colWidths[i]; });
+        doc.moveDown(0.3);
+        doc.moveTo(50, doc.y).lineTo(50 + W, doc.y).strokeColor('#e5e7eb').stroke();
+        doc.moveDown(0.3);
+        chargesByCategory.forEach(c => {
+          const pct = totalChargesReelles > 0 ? (c.total / totalChargesReelles * 100).toFixed(1) : '0.0';
+          tx = 50; ty = doc.y;
+          doc.fontSize(9).fillColor('#111827');
+          [catLabels[c.category] || c.category, fmtEur(c.total), `${pct}%`].forEach((v, i) => { doc.text(v, tx, ty, { width: colWidths[i] }); tx += colWidths[i]; });
+          doc.moveDown(0.4);
+        });
+        tx = 50; ty = doc.y;
+        doc.fontSize(9).fillColor('#111827').font('Helvetica-Bold');
+        ['Total', fmtEur(totalChargesReelles), ''].forEach((v, i) => { doc.text(v, tx, ty, { width: colWidths[i] }); tx += colWidths[i]; });
+        doc.font('Helvetica');
+        doc.moveDown(1.5);
+      }
+
+      // Per tenant bilan
+      if (bilans.length > 0) {
+        doc.fontSize(11).fillColor('#111827').text('Décompte par locataire', { underline: true });
+        doc.moveDown(0.5);
+        bilans.forEach(b => {
+          const isPos = b.trop_percu >= 0;
+          const color = isPos ? green : orange;
+          const bY = doc.y;
+          doc.rect(50, bY, W, 90).stroke('#e5e7eb');
+          doc.fontSize(11).fillColor('#111827').font('Helvetica-Bold').text(b.tenant_name, 62, bY + 10, { width: W - 24 });
+          doc.font('Helvetica');
+          if (b.tenant_email) doc.fontSize(9).fillColor(gray).text(b.tenant_email, 62, bY + 24);
+          const badge = isPos ? 'Remboursement' : 'Appel de fonds';
+          doc.fontSize(9).fillColor(color).text(badge, 62, bY + 38);
+          const cols = [['Provisions perçues', fmtEur(b.provisions_percues)], [`Quote-part (${b.ratio_percent}%)`, fmtEur(b.quote_part_charges)], [isPos ? 'Trop-perçu' : 'Solde dû', fmtEur(Math.abs(b.trop_percu))]];
+          const cw = (W - 24) / 3;
+          cols.forEach(([lbl, val], i) => {
+            const cx = 62 + i * (cw + 8);
+            doc.fontSize(8).fillColor(gray).text(lbl, cx, bY + 54, { width: cw });
+            doc.fontSize(10).fillColor(i === 2 ? color : '#111827').font('Helvetica-Bold').text(val, cx, bY + 66, { width: cw });
+            doc.font('Helvetica');
+          });
+          doc.y = bY + 100;
+          doc.moveDown(0.3);
+        });
+      }
+
+      // Charges list
+      if (chargesList.length > 0) {
+        doc.addPage();
+        doc.fontSize(13).fillColor('#111827').text(`Détail des charges ${year}`, { underline: true });
+        doc.moveDown(0.5);
+        const hdrs = ['Date', 'Catégorie', 'Libellé', 'Montant', 'Facture'];
+        const cws2 = [70, 100, 180, 80, 100];
+        let hx = 50; const hy = doc.y;
+        doc.fontSize(9).fillColor(gray);
+        hdrs.forEach((h, i) => { doc.text(h, hx, hy, { width: cws2[i] }); hx += cws2[i]; });
+        doc.moveDown(0.3);
+        doc.moveTo(50, doc.y).lineTo(50 + W, doc.y).strokeColor('#e5e7eb').stroke();
+        doc.moveDown(0.3);
+        chargesList.forEach(c => {
+          if (doc.y > doc.page.height - 80) { doc.addPage(); }
+          hx = 50; const row = doc.y;
+          doc.fontSize(9).fillColor('#111827');
+          [fmtDate(c.date), catLabels[c.category] || c.category, c.label, fmtEur(c.amount), c.invoice_name ? `📎 Voir annexe` : '—'].forEach((v, i) => {
+            doc.text(v, hx, row, { width: cws2[i] }); hx += cws2[i];
+          });
+          doc.moveDown(0.5);
+        });
+      }
+
+      doc.end();
+    });
+
+    // --- Step 2: Merge bilan + invoice files with pdf-lib ---
+    const mergedPdf = await LibPDFDocument.create();
+
+    // Copy bilan pages
+    const bilanPdf = await LibPDFDocument.load(bilanBuf);
+    const bilanPages = await mergedPdf.copyPages(bilanPdf, bilanPdf.getPageIndices());
+    bilanPages.forEach(p => mergedPdf.addPage(p));
+
+    // Append each invoice
+    for (const charge of chargesList) {
+      if (!charge.invoice_path) continue;
+      const filePath = path.join(uploadDir, charge.invoice_path);
+      if (!fs.existsSync(filePath)) continue;
+      const ext = path.extname(charge.invoice_path).toLowerCase();
+      const fileBytes = fs.readFileSync(filePath);
+
+      if (ext === '.pdf') {
+        try {
+          const invPdf = await LibPDFDocument.load(fileBytes, { ignoreEncryption: true });
+          const invPages = await mergedPdf.copyPages(invPdf, invPdf.getPageIndices());
+          invPages.forEach(p => mergedPdf.addPage(p));
+        } catch { /* skip unreadable PDFs */ }
+      } else if (['.jpg', '.jpeg'].includes(ext)) {
+        try {
+          const img = await mergedPdf.embedJpg(fileBytes);
+          const page = mergedPdf.addPage([img.width, img.height]);
+          page.drawImage(img, { x: 0, y: 0, width: img.width, height: img.height });
+        } catch { /* skip unreadable images */ }
+      } else if (ext === '.png') {
+        try {
+          const img = await mergedPdf.embedPng(fileBytes);
+          const page = mergedPdf.addPage([img.width, img.height]);
+          page.drawImage(img, { x: 0, y: 0, width: img.width, height: img.height });
+        } catch { /* skip unreadable images */ }
+      }
+      // webp not supported by pdf-lib natively — skipped
+    }
+
+    const pdfBytes = await mergedPdf.save();
+    const filename = `bilan_charges_${year}_${prop.name.replace(/[^a-z0-9]/gi, '_')}.pdf`;
+    res.setHeader('Content-Type', 'application/pdf');
+    res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
+    res.send(Buffer.from(pdfBytes));
+  } catch (err) {
+    console.error('PDF generation error:', err);
+    res.status(500).json({ error: 'Erreur génération PDF' });
+  }
+});
+
 module.exports = router;

+ 11 - 5
backend/src/routes/receipts.js

@@ -14,7 +14,9 @@ router.get('/:paymentId', (req, res) => {
       l.rent_amount, l.charges_amount, l.deposit_amount,
       pr.name as property_name, pr.address as property_address, pr.city as property_city, pr.zip_code as property_zip,
       t.first_name as tenant_first_name, t.last_name as tenant_last_name, t.email as tenant_email,
-      u.name as owner_name, u.email as owner_email
+      u.name as owner_name, u.email as owner_email,
+      u.first_name as owner_first_name, u.last_name as owner_last_name,
+      u.address as owner_address, u.phone as owner_phone
     FROM payments p
     JOIN leases l ON p.lease_id = l.id
     JOIN properties pr ON l.property_id = pr.id
@@ -45,10 +47,14 @@ router.get('/:paymentId', (req, res) => {
   doc.moveDown(1);
 
   // Bailleur
+  const ownerFullName = (payment.owner_first_name && payment.owner_last_name)
+    ? `${payment.owner_first_name} ${payment.owner_last_name}`
+    : payment.owner_name;
   doc.fontSize(12).font('Helvetica-Bold').text('BAILLEUR');
-  doc.font('Helvetica').fontSize(11)
-    .text(payment.owner_name)
-    .text(payment.owner_email);
+  doc.font('Helvetica').fontSize(11).text(ownerFullName);
+  if (payment.owner_address) doc.text(payment.owner_address);
+  doc.text(payment.owner_email);
+  if (payment.owner_phone) doc.text(payment.owner_phone);
   doc.moveDown(1);
 
   // Locataire
@@ -109,7 +115,7 @@ router.get('/:paymentId', (req, res) => {
   doc.moveDown(1);
   doc.font('Helvetica').fontSize(10).fillColor('#6b7280')
     .text(
-      `Je soussigné(e) ${payment.owner_name}, bailleur, donne quittance à ${payment.tenant_first_name} ${payment.tenant_last_name} ` +
+      `Je soussigné(e) ${ownerFullName}, bailleur, donne quittance à ${payment.tenant_first_name} ${payment.tenant_last_name} ` +
       `pour la somme de ${total.toFixed(2)} € correspondant au paiement du loyer et des charges du logement situé ` +
       `${payment.property_address}, ${payment.property_zip} ${payment.property_city}, ` +
       `pour la période de ${monthName} ${payment.period_year}.`,

+ 2 - 0
frontend/src/App.jsx

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

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

@@ -8,6 +8,7 @@ const navItems = [
   { to: '/leases', label: '📋 Baux' },
   { to: '/payments', label: '💰 Paiements' },
   { to: '/charges', label: '🧾 Charges' },
+  { to: '/profile', label: '⚙️ Mon profil' },
 ]
 
 export default function Layout() {
@@ -22,7 +23,9 @@ export default function Layout() {
       <aside className="w-64 bg-white border-r border-gray-200 flex flex-col">
         <div className="p-6 border-b border-gray-100">
           <h1 className="text-xl font-bold text-blue-700">🏠 Gestion Locative</h1>
-          <p className="text-sm text-gray-500 mt-1">{user?.name}</p>
+          <p className="text-sm text-gray-500 mt-1">
+            {user?.first_name ? `${user.first_name} ${user.last_name || ''}`.trim() : user?.name}
+          </p>
         </div>
         <nav className="flex-1 p-4 space-y-1">
           {navItems.map(({ to, label, end }) => (

+ 4 - 2
frontend/src/context/AuthContext.jsx

@@ -22,7 +22,9 @@ export function AuthProvider({ children }) {
   const login = async (email, password) => {
     const res = await api.post('/auth/login', { email, password })
     localStorage.setItem('token', res.data.token)
-    setUser(res.data.user)
+    // Fetch full profile (includes new fields)
+    const profile = await api.get('/auth/me')
+    setUser(profile.data)
   }
 
   const register = async (email, password, name) => {
@@ -37,7 +39,7 @@ export function AuthProvider({ children }) {
   }
 
   return (
-    <AuthContext.Provider value={{ user, loading, login, register, logout }}>
+    <AuthContext.Provider value={{ user, setUser, loading, login, register, logout }}>
       {children}
     </AuthContext.Provider>
   )

+ 41 - 3
frontend/src/pages/Charges.jsx

@@ -1,6 +1,7 @@
 import { useEffect, useState } from 'react'
 import api from '../api'
 import toast from 'react-hot-toast'
+import { useAuth } from '../context/AuthContext'
 
 const CATEGORIES = [
   { value: 'eau', label: '💧 Eau' },
@@ -36,6 +37,7 @@ function CategoryBadge({ value }) {
 }
 
 export default function Charges() {
+  const { user } = useAuth()
   const [properties, setProperties] = useState([])
   const [charges, setCharges] = useState([])
   const [filterProperty, setFilterProperty] = useState('')
@@ -44,6 +46,7 @@ export default function Charges() {
   const [showBilan, setShowBilan] = useState(false)
   const [bilan, setBilan] = useState(null)
   const [bilanLoading, setBilanLoading] = useState(false)
+  const [pdfLoading, setPdfLoading] = useState(false)
   const [loading, setLoading] = useState(false)
   const [editCharge, setEditCharge] = useState(null)
 
@@ -156,6 +159,14 @@ export default function Charges() {
     const fmtEur = v => `${Number(v).toFixed(2)} €`
     const solde = bilan.total_provisions_percues - bilan.total_charges_reelles
     const isPos = solde >= 0
+    const ownerName = (user?.first_name && user?.last_name) ? `${user.first_name} ${user.last_name}` : (user?.name || '')
+    const ownerBlock = `<div class="owner-block">
+      <span class="owner-label">BAILLEUR</span>
+      <strong>${ownerName}</strong>
+      ${user?.address ? `<br>${user.address}` : ''}
+      ${user?.email ? `<br>${user.email}` : ''}
+      ${user?.phone ? `<br>${user.phone}` : ''}
+    </div>`
 
     const tenantsRows = bilan.bilans.map(b => {
       const pos = b.trop_percu >= 0
@@ -228,6 +239,8 @@ export default function Charges() {
   .row-green td { background: #f0fdf4; color: #15803d; }
   .row-orange td { background: #fff7ed; color: #c2410c; }
   .footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid #e5e7eb; font-size: 11px; color: #9ca3af; text-align: center; }
+  .owner-block { border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px 14px; margin-bottom: 20px; font-size: 12px; line-height: 1.6; }
+  .owner-label { display: block; font-size: 10px; color: #6b7280; font-weight: 600; margin-bottom: 2px; letter-spacing: 0.05em; }
   @media print {
     body { padding: 20px; }
     .no-print { display: none; }
@@ -238,8 +251,8 @@ export default function Charges() {
   <h1>Bilan annuel de charges ${bilan.year}</h1>
   <p class="subtitle">${bilan.property?.name} — ${bilan.property?.address || ''}</p>
 
+  ${ownerBlock}
   <div class="summary">
-    <div class="card">
       <div class="label">Charges réelles</div>
       <div class="value red">${fmtEur(bilan.total_charges_reelles)}</div>
       <div class="note">${bilan.charges_count} facture${bilan.charges_count > 1 ? 's' : ''}</div>
@@ -275,6 +288,27 @@ export default function Charges() {
     w.document.close()
   }
 
+  const downloadPdf = async () => {
+    if (!filterProperty || !filterYear) return
+    setPdfLoading(true)
+    try {
+      const token = localStorage.getItem('token')
+      const resp = await fetch(`/api/charges/bilan/${filterProperty}/${filterYear}/pdf`, {
+        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
+      const propName = properties.find(p => String(p.id) === filterProperty)?.name || 'bien'
+      a.download = `bilan_charges_${filterYear}_${propName}.pdf`
+      a.click()
+      window.URL.revokeObjectURL(url)
+    } catch { toast.error('Erreur lors de la génération du PDF') }
+    finally { setPdfLoading(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)
@@ -452,10 +486,14 @@ export default function Charges() {
       {showBilan && bilan && (
         <Modal title={`📊 Bilan annuel ${bilan.year} — ${bilan.property?.name}`} onClose={() => setShowBilan(false)}>
           <div className="space-y-6">
-            <div className="flex justify-end">
+            <div className="flex justify-end gap-2">
               <button onClick={printBilan}
+                className="btn-secondary flex items-center gap-2 text-sm">
+                🖨️ Aperçu / Imprimer
+              </button>
+              <button onClick={downloadPdf} disabled={pdfLoading}
                 className="btn-primary flex items-center gap-2 text-sm">
-                🖨️ Imprimer / Exporter PDF
+                {pdfLoading ? '⏳ Génération…' : '📥 Télécharger PDF complet (avec factures)'}
               </button>
             </div>
             {/* Summary cards */}

+ 82 - 0
frontend/src/pages/Profile.jsx

@@ -0,0 +1,82 @@
+import { useState, useEffect } from 'react'
+import api from '../api'
+import toast from 'react-hot-toast'
+import { useAuth } from '../context/AuthContext'
+
+export default function Profile() {
+  const { user, setUser } = useAuth()
+  const [form, setForm] = useState({ first_name: '', last_name: '', email: '', address: '', phone: '', name: '' })
+  const [loading, setLoading] = useState(false)
+
+  useEffect(() => {
+    if (user) {
+      setForm({
+        first_name: user.first_name || '',
+        last_name: user.last_name || '',
+        email: user.email || '',
+        address: user.address || '',
+        phone: user.phone || '',
+        name: user.name || '',
+      })
+    }
+  }, [user])
+
+  const handleSubmit = async e => {
+    e.preventDefault()
+    setLoading(true)
+    try {
+      const { data } = await api.put('/auth/profile', form)
+      setUser(data)
+      toast.success('Profil mis à jour !')
+    } catch (err) {
+      toast.error(err.response?.data?.error || 'Erreur lors de la mise à jour')
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  const field = (label, key, type = 'text', placeholder = '') => (
+    <div>
+      <label className="label">{label}</label>
+      <input
+        className="input"
+        type={type}
+        value={form[key]}
+        placeholder={placeholder}
+        onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))}
+      />
+    </div>
+  )
+
+  return (
+    <div className="max-w-xl">
+      <div className="mb-6">
+        <h1 className="text-2xl font-bold text-gray-900">👤 Mon profil bailleur</h1>
+        <p className="text-gray-500 text-sm mt-1">Ces informations apparaîtront dans vos quittances et bilans de charges</p>
+      </div>
+
+      <div className="card">
+        <form onSubmit={handleSubmit} className="space-y-4">
+          <div className="grid grid-cols-2 gap-4">
+            {field('Prénom', 'first_name', 'text', 'Jean')}
+            {field('Nom', 'last_name', 'text', 'Dupont')}
+          </div>
+          {field('Adresse postale', 'address', 'text', '12 rue de la Paix, 75001 Paris')}
+          {field('Adresse e-mail *', 'email', 'email', 'vous@exemple.fr')}
+          {field('Numéro de téléphone', 'phone', 'tel', '06 00 00 00 00')}
+
+          <div className="pt-2 border-t border-gray-100">
+            <p className="text-xs text-gray-400 mb-3">
+              Le champ "Nom d'affichage" est utilisé si prénom/nom ne sont pas renseignés.
+            </p>
+            {field("Nom d'affichage *", 'name', 'text', 'Jean Dupont')}
+          </div>
+
+          <button type="submit" disabled={loading} className="btn-primary w-full">
+            {loading ? '...' : '💾 Enregistrer le profil'}
+          </button>
+        </form>
+      </div>
+    </div>
+  )
+}