Bläddra i källkod

feat: application de gestion locative complète

- Auth JWT multi-propriétaires (register/login)
- CRUD biens immobiliers (adresse, type, surface)
- CRUD locataires (nom, email, téléphone)
- Gestion des baux (loyer HC, charges, dépôt, dates)
- Encaissement des loyers et charges par période
- Génération de quittances de loyer en PDF (PDFKit)
- Dashboard : baux actifs, loyers attendus/encaissés
- Frontend React + Vite + Tailwind CSS
- Backend Express + SQLite (better-sqlite3)
- Docker Compose + Nginx pour déploiement

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Jérémy 4 månader sedan
incheckning
a2b7f63ba0

+ 5 - 0
.gitignore

@@ -0,0 +1,5 @@
+node_modules/
+backend/data/
+*.db
+.env
+dist/

+ 78 - 0
README.md

@@ -0,0 +1,78 @@
+# 🏠 Gestion Locative
+
+Application web de gestion locative : encaissement des loyers, suivi des charges, génération de quittances PDF.
+
+## Fonctionnalités
+
+- 🔐 **Multi-propriétaires** avec authentification sécurisée (JWT)
+- 🏠 **Gestion des biens** immobiliers (CRUD)
+- 👤 **Gestion des locataires** (CRUD)
+- 📋 **Baux** : loyer HC, charges, dépôt de garantie, dates
+- 💰 **Encaissements** : enregistrer les paiements mois par mois
+- 📄 **Quittances PDF** générées automatiquement et téléchargeables
+- 📊 **Dashboard** : baux actifs, loyers attendus vs encaissés
+
+## Démarrage rapide
+
+### Option 1 : Docker (recommandé)
+
+```bash
+docker-compose up --build
+```
+
+Puis ouvrez : http://localhost:3000
+
+### Option 2 : Node.js (développement local)
+
+**Prérequis** : Node.js 18+ et npm
+
+```bash
+# Backend
+cd backend
+npm install
+npm run dev    # Démarre sur http://localhost:3001
+
+# Frontend (dans un autre terminal)
+cd frontend
+npm install
+npm run dev    # Démarre sur http://localhost:3000
+```
+
+## Stack technique
+
+| Couche | Technologie |
+|--------|-------------|
+| Backend | Node.js + Express |
+| Base de données | SQLite (better-sqlite3) |
+| Auth | JWT + bcrypt |
+| PDF | PDFKit |
+| Frontend | React 18 + Vite |
+| UI | Tailwind CSS |
+| Déploiement | Docker + Nginx |
+
+## Structure
+
+```
+TestApp/
+├── backend/
+│   ├── src/
+│   │   ├── routes/       # auth, properties, tenants, leases, payments, receipts
+│   │   ├── db.js         # SQLite + migrations
+│   │   ├── auth.js       # JWT middleware
+│   │   └── server.js
+│   └── data/             # Fichier SQLite (créé automatiquement)
+└── frontend/
+    └── src/
+        ├── pages/        # Dashboard, Properties, Tenants, Leases, Payments
+        ├── components/   # Layout
+        └── context/      # AuthContext
+```
+
+## Utilisation
+
+1. **Créer un compte** → Register
+2. **Ajouter vos biens** → 🏠 Biens
+3. **Ajouter vos locataires** → 👤 Locataires
+4. **Créer un bail** → 📋 Baux (associer bien + locataire + montants)
+5. **Enregistrer un paiement** → Cliquer sur un bail → "Enregistrer un paiement"
+6. **Télécharger la quittance PDF** → Bouton 📄 Quittance sur chaque paiement

+ 14 - 0
backend/Dockerfile

@@ -0,0 +1,14 @@
+FROM node:20-alpine
+
+WORKDIR /app
+
+COPY package*.json ./
+RUN npm install --production
+
+COPY src ./src
+
+RUN mkdir -p data
+
+EXPOSE 3001
+
+CMD ["node", "src/server.js"]

+ 20 - 0
backend/package.json

@@ -0,0 +1,20 @@
+{
+  "name": "gestion-locative-backend",
+  "version": "1.0.0",
+  "type": "commonjs",
+  "scripts": {
+    "start": "node src/server.js",
+    "dev": "nodemon src/server.js"
+  },
+  "dependencies": {
+    "bcryptjs": "^2.4.3",
+    "better-sqlite3": "^9.4.3",
+    "cors": "^2.8.5",
+    "express": "^4.18.2",
+    "jsonwebtoken": "^9.0.2",
+    "pdfkit": "^0.15.0"
+  },
+  "devDependencies": {
+    "nodemon": "^3.0.3"
+  }
+}

+ 19 - 0
backend/src/auth.js

@@ -0,0 +1,19 @@
+const jwt = require('jsonwebtoken');
+
+const JWT_SECRET = process.env.JWT_SECRET || 'gestion_locative_secret_2024';
+
+const authMiddleware = (req, res, next) => {
+  const authHeader = req.headers['authorization'];
+  const token = authHeader && authHeader.split(' ')[1];
+  if (!token) return res.status(401).json({ error: 'Token manquant' });
+
+  try {
+    const decoded = jwt.verify(token, JWT_SECRET);
+    req.userId = decoded.userId;
+    next();
+  } catch {
+    res.status(403).json({ error: 'Token invalide' });
+  }
+};
+
+module.exports = { authMiddleware, JWT_SECRET };

+ 78 - 0
backend/src/db.js

@@ -0,0 +1,78 @@
+const Database = require('better-sqlite3');
+const path = require('path');
+
+const db = new Database(path.join(__dirname, '../data/gestion_locative.db'));
+
+db.pragma('journal_mode = WAL');
+db.pragma('foreign_keys = ON');
+
+db.exec(`
+  CREATE TABLE IF NOT EXISTS users (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    email TEXT UNIQUE NOT NULL,
+    password TEXT NOT NULL,
+    name TEXT NOT NULL,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+  );
+
+  CREATE TABLE IF NOT EXISTS properties (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    user_id INTEGER NOT NULL,
+    name TEXT NOT NULL,
+    address TEXT NOT NULL,
+    city TEXT NOT NULL,
+    zip_code TEXT NOT NULL,
+    type TEXT NOT NULL,
+    rooms INTEGER,
+    area REAL,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    FOREIGN KEY (user_id) REFERENCES users(id)
+  );
+
+  CREATE TABLE IF NOT EXISTS tenants (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    user_id INTEGER NOT NULL,
+    first_name TEXT NOT NULL,
+    last_name TEXT NOT NULL,
+    email TEXT,
+    phone TEXT,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    FOREIGN KEY (user_id) REFERENCES users(id)
+  );
+
+  CREATE TABLE IF NOT EXISTS leases (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    user_id INTEGER NOT NULL,
+    property_id INTEGER NOT NULL,
+    tenant_id INTEGER NOT NULL,
+    start_date TEXT NOT NULL,
+    end_date TEXT,
+    rent_amount REAL NOT NULL,
+    charges_amount REAL NOT NULL DEFAULT 0,
+    deposit_amount REAL NOT NULL DEFAULT 0,
+    payment_day INTEGER NOT NULL DEFAULT 1,
+    active INTEGER NOT NULL DEFAULT 1,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    FOREIGN KEY (user_id) REFERENCES users(id),
+    FOREIGN KEY (property_id) REFERENCES properties(id),
+    FOREIGN KEY (tenant_id) REFERENCES tenants(id)
+  );
+
+  CREATE TABLE IF NOT EXISTS payments (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    lease_id INTEGER NOT NULL,
+    user_id INTEGER NOT NULL,
+    period_month INTEGER NOT NULL,
+    period_year INTEGER NOT NULL,
+    rent_paid REAL NOT NULL,
+    charges_paid REAL NOT NULL DEFAULT 0,
+    payment_date TEXT NOT NULL,
+    payment_method TEXT NOT NULL DEFAULT 'virement',
+    notes TEXT,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    FOREIGN KEY (lease_id) REFERENCES leases(id),
+    FOREIGN KEY (user_id) REFERENCES users(id)
+  );
+`);
+
+module.exports = db;

+ 46 - 0
backend/src/routes/auth.js

@@ -0,0 +1,46 @@
+const express = require('express');
+const bcrypt = require('bcryptjs');
+const jwt = require('jsonwebtoken');
+const db = require('../db');
+const { JWT_SECRET } = require('../auth');
+
+const router = express.Router();
+
+router.post('/register', async (req, res) => {
+  const { email, password, name } = req.body;
+  if (!email || !password || !name)
+    return res.status(400).json({ error: 'Tous les champs sont requis' });
+
+  try {
+    const hashed = await bcrypt.hash(password, 10);
+    const stmt = db.prepare('INSERT INTO users (email, password, name) VALUES (?, ?, ?)');
+    const result = stmt.run(email, hashed, name);
+    const token = jwt.sign({ userId: result.lastInsertRowid }, JWT_SECRET, { expiresIn: '7d' });
+    res.json({ token, user: { id: result.lastInsertRowid, email, name } });
+  } catch (err) {
+    if (err.message.includes('UNIQUE')) return res.status(409).json({ error: 'Email déjà utilisé' });
+    res.status(500).json({ error: err.message });
+  }
+});
+
+router.post('/login', async (req, res) => {
+  const { email, password } = req.body;
+  if (!email || !password)
+    return res.status(400).json({ error: 'Email et mot de passe requis' });
+
+  const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
+  if (!user) return res.status(401).json({ error: 'Identifiants incorrects' });
+
+  const valid = await bcrypt.compare(password, user.password);
+  if (!valid) return res.status(401).json({ error: 'Identifiants incorrects' });
+
+  const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
+  res.json({ token, user: { id: user.id, email: user.email, name: user.name } });
+});
+
+router.get('/me', require('../auth').authMiddleware, (req, res) => {
+  const user = db.prepare('SELECT id, email, name FROM users WHERE id = ?').get(req.userId);
+  res.json(user);
+});
+
+module.exports = router;

+ 68 - 0
backend/src/routes/leases.js

@@ -0,0 +1,68 @@
+const express = require('express');
+const db = require('../db');
+const { authMiddleware } = require('../auth');
+
+const router = express.Router();
+router.use(authMiddleware);
+
+router.get('/', (req, res) => {
+  const rows = db.prepare(`
+    SELECT l.*, 
+      p.name as property_name, p.address as property_address, p.city as property_city, p.zip_code as property_zip,
+      t.first_name as tenant_first_name, t.last_name as tenant_last_name, t.email as tenant_email
+    FROM leases l
+    JOIN properties p ON l.property_id = p.id
+    JOIN tenants t ON l.tenant_id = t.id
+    WHERE l.user_id = ?
+    ORDER BY l.start_date DESC
+  `).all(req.userId);
+  res.json(rows);
+});
+
+router.get('/:id', (req, res) => {
+  const row = db.prepare(`
+    SELECT l.*,
+      p.name as property_name, p.address as property_address, p.city as property_city, p.zip_code as property_zip,
+      t.first_name as tenant_first_name, t.last_name as tenant_last_name, t.email as tenant_email
+    FROM leases l
+    JOIN properties p ON l.property_id = p.id
+    JOIN tenants t ON l.tenant_id = t.id
+    WHERE l.id = ? AND l.user_id = ?
+  `).get(req.params.id, req.userId);
+  if (!row) return res.status(404).json({ error: 'Bail non trouvé' });
+  res.json(row);
+});
+
+router.post('/', (req, res) => {
+  const { property_id, tenant_id, start_date, end_date, rent_amount, charges_amount, deposit_amount, payment_day } = req.body;
+  if (!property_id || !tenant_id || !start_date || !rent_amount)
+    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 result = db.prepare(`
+    INSERT INTO leases (user_id, property_id, tenant_id, start_date, end_date, rent_amount, charges_amount, deposit_amount, payment_day)
+    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+  `).run(req.userId, property_id, tenant_id, start_date, end_date || null, rent_amount, charges_amount || 0, deposit_amount || 0, payment_day || 1);
+  res.status(201).json({ id: result.lastInsertRowid });
+});
+
+router.put('/:id', (req, res) => {
+  const lease = db.prepare('SELECT id FROM leases WHERE id = ? AND user_id = ?').get(req.params.id, req.userId);
+  if (!lease) return res.status(404).json({ error: 'Bail non trouvé' });
+  const { end_date, rent_amount, charges_amount, deposit_amount, payment_day, active } = req.body;
+  db.prepare(`
+    UPDATE leases SET end_date=?, rent_amount=?, charges_amount=?, deposit_amount=?, payment_day=?, active=? WHERE id=?
+  `).run(end_date || null, rent_amount, charges_amount || 0, deposit_amount || 0, payment_day || 1, active !== undefined ? active : 1, req.params.id);
+  res.json({ success: true });
+});
+
+router.delete('/:id', (req, res) => {
+  const lease = db.prepare('SELECT id FROM leases WHERE id = ? AND user_id = ?').get(req.params.id, req.userId);
+  if (!lease) return res.status(404).json({ error: 'Bail non trouvé' });
+  db.prepare('DELETE FROM leases WHERE id = ?').run(req.params.id);
+  res.json({ success: true });
+});
+
+module.exports = router;

+ 65 - 0
backend/src/routes/payments.js

@@ -0,0 +1,65 @@
+const express = require('express');
+const db = require('../db');
+const { authMiddleware } = require('../auth');
+
+const router = express.Router();
+router.use(authMiddleware);
+
+router.get('/', (req, res) => {
+  const { lease_id } = req.query;
+  let query = `
+    SELECT p.*, l.rent_amount, l.charges_amount,
+      pr.name as property_name, pr.address as property_address, pr.city as property_city,
+      t.first_name as tenant_first_name, t.last_name as tenant_last_name
+    FROM payments p
+    JOIN leases l ON p.lease_id = l.id
+    JOIN properties pr ON l.property_id = pr.id
+    JOIN tenants t ON l.tenant_id = t.id
+    WHERE p.user_id = ?
+  `;
+  const params = [req.userId];
+  if (lease_id) { query += ' AND p.lease_id = ?'; params.push(lease_id); }
+  query += ' ORDER BY p.period_year DESC, p.period_month DESC';
+  res.json(db.prepare(query).all(...params));
+});
+
+router.post('/', (req, res) => {
+  const { lease_id, period_month, period_year, rent_paid, charges_paid, payment_date, payment_method, notes } = req.body;
+  if (!lease_id || !period_month || !period_year || rent_paid === undefined || !payment_date)
+    return res.status(400).json({ error: 'Champs requis manquants' });
+
+  const lease = db.prepare('SELECT id FROM leases WHERE id = ? AND user_id = ?').get(lease_id, req.userId);
+  if (!lease) return res.status(403).json({ error: 'Bail non autorisé' });
+
+  const existing = db.prepare(
+    'SELECT id FROM payments WHERE lease_id = ? AND period_month = ? AND period_year = ?'
+  ).get(lease_id, period_month, period_year);
+  if (existing) return res.status(409).json({ error: 'Un paiement existe déjà pour cette période' });
+
+  const result = db.prepare(`
+    INSERT INTO payments (lease_id, user_id, period_month, period_year, rent_paid, charges_paid, payment_date, payment_method, notes)
+    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+  `).run(lease_id, req.userId, period_month, period_year, rent_paid, charges_paid || 0, payment_date, payment_method || 'virement', notes || null);
+  res.status(201).json({ id: result.lastInsertRowid });
+});
+
+router.delete('/:id', (req, res) => {
+  const payment = db.prepare('SELECT id FROM payments WHERE id = ? AND user_id = ?').get(req.params.id, req.userId);
+  if (!payment) return res.status(404).json({ error: 'Paiement non trouvé' });
+  db.prepare('DELETE FROM payments WHERE id = ?').run(req.params.id);
+  res.json({ success: true });
+});
+
+// Dashboard stats
+router.get('/stats', (req, res) => {
+  const stats = db.prepare(`
+    SELECT 
+      COUNT(DISTINCT l.id) as active_leases,
+      SUM(l.rent_amount + l.charges_amount) as monthly_expected,
+      (SELECT SUM(rent_paid + charges_paid) FROM payments WHERE user_id = ? AND period_year = strftime('%Y', 'now') AND period_month = strftime('%m', 'now')) as current_month_collected
+    FROM leases l WHERE l.user_id = ? AND l.active = 1
+  `).get(req.userId, req.userId);
+  res.json(stats);
+});
+
+module.exports = router;

+ 40 - 0
backend/src/routes/properties.js

@@ -0,0 +1,40 @@
+const express = require('express');
+const db = require('../db');
+const { authMiddleware } = require('../auth');
+
+const router = express.Router();
+router.use(authMiddleware);
+
+router.get('/', (req, res) => {
+  const rows = db.prepare('SELECT * FROM properties WHERE user_id = ? ORDER BY created_at DESC').all(req.userId);
+  res.json(rows);
+});
+
+router.post('/', (req, res) => {
+  const { name, address, city, zip_code, type, rooms, area } = req.body;
+  if (!name || !address || !city || !zip_code || !type)
+    return res.status(400).json({ error: 'Champs requis manquants' });
+  const result = db.prepare(
+    'INSERT INTO properties (user_id, name, address, city, zip_code, type, rooms, area) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
+  ).run(req.userId, name, address, city, zip_code, type, rooms || null, area || null);
+  res.status(201).json({ id: result.lastInsertRowid });
+});
+
+router.put('/:id', (req, res) => {
+  const { name, address, city, zip_code, type, rooms, area } = req.body;
+  const prop = db.prepare('SELECT id FROM properties WHERE id = ? AND user_id = ?').get(req.params.id, req.userId);
+  if (!prop) return res.status(404).json({ error: 'Bien non trouvé' });
+  db.prepare(
+    'UPDATE properties SET name=?, address=?, city=?, zip_code=?, type=?, rooms=?, area=? WHERE id=?'
+  ).run(name, address, city, zip_code, type, rooms || null, area || null, req.params.id);
+  res.json({ success: true });
+});
+
+router.delete('/:id', (req, res) => {
+  const prop = db.prepare('SELECT id FROM properties WHERE id = ? AND user_id = ?').get(req.params.id, req.userId);
+  if (!prop) return res.status(404).json({ error: 'Bien non trouvé' });
+  db.prepare('DELETE FROM properties WHERE id = ?').run(req.params.id);
+  res.json({ success: true });
+});
+
+module.exports = router;

+ 129 - 0
backend/src/routes/receipts.js

@@ -0,0 +1,129 @@
+const express = require('express');
+const PDFDocument = require('pdfkit');
+const db = require('../db');
+const { authMiddleware } = require('../auth');
+
+const router = express.Router();
+router.use(authMiddleware);
+
+const MONTHS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
+
+router.get('/:paymentId', (req, res) => {
+  const payment = db.prepare(`
+    SELECT p.*,
+      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
+    FROM payments p
+    JOIN leases l ON p.lease_id = l.id
+    JOIN properties pr ON l.property_id = pr.id
+    JOIN tenants t ON l.tenant_id = t.id
+    JOIN users u ON p.user_id = u.id
+    WHERE p.id = ? AND p.user_id = ?
+  `).get(req.params.paymentId, req.userId);
+
+  if (!payment) return res.status(404).json({ error: 'Paiement non trouvé' });
+
+  const doc = new PDFDocument({ size: 'A4', margin: 50 });
+
+  res.setHeader('Content-Type', 'application/pdf');
+  res.setHeader('Content-Disposition', `attachment; filename=quittance_${payment.period_year}_${String(payment.period_month).padStart(2,'0')}.pdf`);
+  doc.pipe(res);
+
+  const monthName = MONTHS_FR[payment.period_month - 1];
+  const total = payment.rent_paid + payment.charges_paid;
+
+  // Header
+  doc.fontSize(22).font('Helvetica-Bold').text('QUITTANCE DE LOYER', { align: 'center' });
+  doc.moveDown(0.3);
+  doc.fontSize(14).font('Helvetica').text(`Période : ${monthName} ${payment.period_year}`, { align: 'center' });
+  doc.moveDown(1.5);
+
+  // Separator
+  doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#2563eb').lineWidth(2).stroke();
+  doc.moveDown(1);
+
+  // Bailleur
+  doc.fontSize(12).font('Helvetica-Bold').text('BAILLEUR');
+  doc.font('Helvetica').fontSize(11)
+    .text(payment.owner_name)
+    .text(payment.owner_email);
+  doc.moveDown(1);
+
+  // Locataire
+  doc.fontSize(12).font('Helvetica-Bold').text('LOCATAIRE');
+  doc.font('Helvetica').fontSize(11)
+    .text(`${payment.tenant_first_name} ${payment.tenant_last_name}`)
+    .text(payment.tenant_email || '');
+  doc.moveDown(1);
+
+  // Bien loué
+  doc.fontSize(12).font('Helvetica-Bold').text('BIEN LOUÉ');
+  doc.font('Helvetica').fontSize(11)
+    .text(payment.property_name)
+    .text(`${payment.property_address}`)
+    .text(`${payment.property_zip} ${payment.property_city}`);
+  doc.moveDown(1);
+
+  // Separator
+  doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e5e7eb').lineWidth(1).stroke();
+  doc.moveDown(1);
+
+  // Détail paiement
+  doc.fontSize(12).font('Helvetica-Bold').text('DÉTAIL DU RÈGLEMENT');
+  doc.moveDown(0.5);
+
+  const tableTop = doc.y;
+  const col1 = 50, col2 = 400;
+
+  const drawRow = (label, value, bold = false) => {
+    const y = doc.y;
+    doc.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(11);
+    doc.text(label, col1, y);
+    doc.text(`${value.toFixed(2)} €`, col2, y, { width: 100, align: 'right' });
+    doc.moveDown(0.6);
+  };
+
+  drawRow('Loyer hors charges', payment.rent_paid);
+  drawRow('Charges', payment.charges_paid);
+  doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e5e7eb').lineWidth(0.5).stroke();
+  doc.moveDown(0.3);
+  drawRow('TOTAL', total, true);
+  doc.moveDown(0.5);
+
+  // Mode et date de paiement
+  const paymentMethods = { virement: 'Virement bancaire', cheque: 'Chèque', especes: 'Espèces', prelevement: 'Prélèvement automatique' };
+  doc.font('Helvetica').fontSize(11)
+    .text(`Mode de paiement : ${paymentMethods[payment.payment_method] || payment.payment_method}`)
+    .text(`Date de paiement : ${new Date(payment.payment_date).toLocaleDateString('fr-FR')}`);
+
+  if (payment.notes) {
+    doc.moveDown(0.5).text(`Notes : ${payment.notes}`);
+  }
+
+  doc.moveDown(2);
+
+  // Attestation
+  doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e5e7eb').lineWidth(1).stroke();
+  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} ` +
+      `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}.`,
+      { align: 'justify' }
+    );
+
+  doc.moveDown(3);
+  doc.fontSize(11).fillColor('#000000').text(`Fait le ${new Date().toLocaleDateString('fr-FR')}`);
+  doc.moveDown(2);
+  doc.text('Signature du bailleur :', { continued: false });
+  doc.moveDown(1);
+  doc.moveTo(50, doc.y).lineTo(200, doc.y).strokeColor('#000').lineWidth(1).stroke();
+
+  doc.end();
+});
+
+module.exports = router;

+ 38 - 0
backend/src/routes/tenants.js

@@ -0,0 +1,38 @@
+const express = require('express');
+const db = require('../db');
+const { authMiddleware } = require('../auth');
+
+const router = express.Router();
+router.use(authMiddleware);
+
+router.get('/', (req, res) => {
+  const rows = db.prepare('SELECT * FROM tenants WHERE user_id = ? ORDER BY last_name, first_name').all(req.userId);
+  res.json(rows);
+});
+
+router.post('/', (req, res) => {
+  const { first_name, last_name, email, phone } = req.body;
+  if (!first_name || !last_name) return res.status(400).json({ error: 'Nom et prénom requis' });
+  const result = db.prepare(
+    'INSERT INTO tenants (user_id, first_name, last_name, email, phone) VALUES (?, ?, ?, ?, ?)'
+  ).run(req.userId, first_name, last_name, email || null, phone || null);
+  res.status(201).json({ id: result.lastInsertRowid });
+});
+
+router.put('/:id', (req, res) => {
+  const { first_name, last_name, email, phone } = req.body;
+  const tenant = db.prepare('SELECT id FROM tenants WHERE id = ? AND user_id = ?').get(req.params.id, req.userId);
+  if (!tenant) return res.status(404).json({ error: 'Locataire non trouvé' });
+  db.prepare('UPDATE tenants SET first_name=?, last_name=?, email=?, phone=? WHERE id=?')
+    .run(first_name, last_name, email || null, phone || null, req.params.id);
+  res.json({ success: true });
+});
+
+router.delete('/:id', (req, res) => {
+  const tenant = db.prepare('SELECT id FROM tenants WHERE id = ? AND user_id = ?').get(req.params.id, req.userId);
+  if (!tenant) return res.status(404).json({ error: 'Locataire non trouvé' });
+  db.prepare('DELETE FROM tenants WHERE id = ?').run(req.params.id);
+  res.json({ success: true });
+});
+
+module.exports = router;

+ 25 - 0
backend/src/server.js

@@ -0,0 +1,25 @@
+const express = require('express');
+const cors = require('cors');
+const path = require('path');
+const fs = require('fs');
+
+// Ensure data directory exists
+const dataDir = path.join(__dirname, '../data');
+if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
+
+const app = express();
+app.use(cors());
+app.use(express.json());
+
+// Routes
+app.use('/api/auth', require('./routes/auth'));
+app.use('/api/properties', require('./routes/properties'));
+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.get('/api/health', (_, res) => res.json({ ok: true }));
+
+const PORT = process.env.PORT || 3001;
+app.listen(PORT, () => console.log(`Server running on http://localhost:${PORT}`));

+ 27 - 0
docker-compose.yml

@@ -0,0 +1,27 @@
+version: '3.8'
+
+services:
+  backend:
+    build:
+      context: ./backend
+      dockerfile: Dockerfile
+    ports:
+      - "3001:3001"
+    volumes:
+      - ./backend/data:/app/data
+    environment:
+      - NODE_ENV=production
+      - JWT_SECRET=change_this_secret_in_production
+    restart: unless-stopped
+
+  frontend:
+    build:
+      context: ./frontend
+      dockerfile: Dockerfile
+      args:
+        VITE_API_URL: http://localhost:3001
+    ports:
+      - "3000:80"
+    depends_on:
+      - backend
+    restart: unless-stopped

+ 16 - 0
frontend/Dockerfile

@@ -0,0 +1,16 @@
+FROM node:20-alpine AS build
+
+WORKDIR /app
+
+COPY package*.json ./
+RUN npm install
+
+COPY . .
+RUN npm run build
+
+FROM nginx:alpine
+COPY --from=build /app/dist /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+
+EXPOSE 80
+CMD ["nginx", "-g", "daemon off;"]

+ 13 - 0
frontend/index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="fr">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Gestion Locative</title>
+    <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏠</text></svg>" />
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/main.jsx"></script>
+  </body>
+</html>

+ 18 - 0
frontend/nginx.conf

@@ -0,0 +1,18 @@
+server {
+    listen 80;
+    root /usr/share/nginx/html;
+    index index.html;
+
+    # Proxy API calls to backend
+    location /api/ {
+        proxy_pass http://backend:3001/api/;
+        proxy_http_version 1.1;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+    }
+
+    # SPA fallback
+    location / {
+        try_files $uri $uri/ /index.html;
+    }
+}

+ 27 - 0
frontend/package.json

@@ -0,0 +1,27 @@
+{
+  "name": "gestion-locative-frontend",
+  "version": "1.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "axios": "^1.6.7",
+    "date-fns": "^3.3.1",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0",
+    "react-hot-toast": "^2.4.1",
+    "react-router-dom": "^6.22.0"
+  },
+  "devDependencies": {
+    "@types/react": "^18.2.55",
+    "@types/react-dom": "^18.2.19",
+    "@vitejs/plugin-react": "^4.2.1",
+    "autoprefixer": "^10.4.17",
+    "postcss": "^8.4.35",
+    "tailwindcss": "^3.4.1",
+    "vite": "^5.1.0"
+  }
+}

+ 6 - 0
frontend/postcss.config.js

@@ -0,0 +1,6 @@
+export default {
+  plugins: {
+    tailwindcss: {},
+    autoprefixer: {}
+  }
+}

+ 46 - 0
frontend/src/App.jsx

@@ -0,0 +1,46 @@
+import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
+import { Toaster } from 'react-hot-toast'
+import { AuthProvider, useAuth } from './context/AuthContext'
+import Layout from './components/Layout'
+import Login from './pages/Login'
+import Register from './pages/Register'
+import Dashboard from './pages/Dashboard'
+import Properties from './pages/Properties'
+import Tenants from './pages/Tenants'
+import Leases from './pages/Leases'
+import LeaseDetail from './pages/LeaseDetail'
+import Payments from './pages/Payments'
+
+function PrivateRoute({ children }) {
+  const { user, loading } = useAuth()
+  if (loading) return <div className="min-h-screen flex items-center justify-center"><div className="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600"></div></div>
+  return user ? children : <Navigate to="/login" replace />
+}
+
+function PublicRoute({ children }) {
+  const { user, loading } = useAuth()
+  if (loading) return null
+  return user ? <Navigate to="/" replace /> : children
+}
+
+export default function App() {
+  return (
+    <AuthProvider>
+      <BrowserRouter>
+        <Toaster position="top-right" />
+        <Routes>
+          <Route path="/login" element={<PublicRoute><Login /></PublicRoute>} />
+          <Route path="/register" element={<PublicRoute><Register /></PublicRoute>} />
+          <Route path="/" element={<PrivateRoute><Layout /></PrivateRoute>}>
+            <Route index element={<Dashboard />} />
+            <Route path="properties" element={<Properties />} />
+            <Route path="tenants" element={<Tenants />} />
+            <Route path="leases" element={<Leases />} />
+            <Route path="leases/:id" element={<LeaseDetail />} />
+            <Route path="payments" element={<Payments />} />
+          </Route>
+        </Routes>
+      </BrowserRouter>
+    </AuthProvider>
+  )
+}

+ 22 - 0
frontend/src/api.js

@@ -0,0 +1,22 @@
+import axios from 'axios'
+
+const api = axios.create({ baseURL: '/api' })
+
+api.interceptors.request.use(config => {
+  const token = localStorage.getItem('token')
+  if (token) config.headers.Authorization = `Bearer ${token}`
+  return config
+})
+
+api.interceptors.response.use(
+  res => res,
+  err => {
+    if (err.response?.status === 401 || err.response?.status === 403) {
+      localStorage.removeItem('token')
+      window.location.href = '/login'
+    }
+    return Promise.reject(err)
+  }
+)
+
+export default api

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

@@ -0,0 +1,57 @@
+import { Outlet, NavLink, useNavigate } from 'react-router-dom'
+import { useAuth } from '../context/AuthContext'
+
+const navItems = [
+  { to: '/', label: '📊 Tableau de bord', end: true },
+  { to: '/properties', label: '🏠 Biens' },
+  { to: '/tenants', label: '👤 Locataires' },
+  { to: '/leases', label: '📋 Baux' },
+  { to: '/payments', label: '💰 Paiements' },
+]
+
+export default function Layout() {
+  const { user, logout } = useAuth()
+  const navigate = useNavigate()
+
+  const handleLogout = () => { logout(); navigate('/login') }
+
+  return (
+    <div className="min-h-screen bg-gray-50 flex">
+      {/* Sidebar */}
+      <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>
+        </div>
+        <nav className="flex-1 p-4 space-y-1">
+          {navItems.map(({ to, label, end }) => (
+            <NavLink
+              key={to}
+              to={to}
+              end={end}
+              className={({ isActive }) =>
+                `flex items-center px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
+                  isActive ? 'bg-blue-50 text-blue-700' : 'text-gray-600 hover:bg-gray-50'
+                }`
+              }
+            >
+              {label}
+            </NavLink>
+          ))}
+        </nav>
+        <div className="p-4 border-t border-gray-100">
+          <button onClick={handleLogout} className="w-full text-left px-3 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors">
+            🚪 Déconnexion
+          </button>
+        </div>
+      </aside>
+
+      {/* Main */}
+      <main className="flex-1 overflow-auto">
+        <div className="max-w-6xl mx-auto p-8">
+          <Outlet />
+        </div>
+      </main>
+    </div>
+  )
+}

+ 46 - 0
frontend/src/context/AuthContext.jsx

@@ -0,0 +1,46 @@
+import { createContext, useContext, useState, useEffect } from 'react'
+import api from '../api'
+
+const AuthContext = createContext(null)
+
+export function AuthProvider({ children }) {
+  const [user, setUser] = useState(null)
+  const [loading, setLoading] = useState(true)
+
+  useEffect(() => {
+    const token = localStorage.getItem('token')
+    if (token) {
+      api.get('/auth/me')
+        .then(res => setUser(res.data))
+        .catch(() => localStorage.removeItem('token'))
+        .finally(() => setLoading(false))
+    } else {
+      setLoading(false)
+    }
+  }, [])
+
+  const login = async (email, password) => {
+    const res = await api.post('/auth/login', { email, password })
+    localStorage.setItem('token', res.data.token)
+    setUser(res.data.user)
+  }
+
+  const register = async (email, password, name) => {
+    const res = await api.post('/auth/register', { email, password, name })
+    localStorage.setItem('token', res.data.token)
+    setUser(res.data.user)
+  }
+
+  const logout = () => {
+    localStorage.removeItem('token')
+    setUser(null)
+  }
+
+  return (
+    <AuthContext.Provider value={{ user, loading, login, register, logout }}>
+      {children}
+    </AuthContext.Provider>
+  )
+}
+
+export const useAuth = () => useContext(AuthContext)

+ 33 - 0
frontend/src/index.css

@@ -0,0 +1,33 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer components {
+  .btn-primary {
+    @apply bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed;
+  }
+  .btn-secondary {
+    @apply bg-gray-100 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-200 transition-colors font-medium;
+  }
+  .btn-danger {
+    @apply bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors font-medium;
+  }
+  .card {
+    @apply bg-white rounded-xl shadow-sm border border-gray-100 p-6;
+  }
+  .input {
+    @apply w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent;
+  }
+  .label {
+    @apply block text-sm font-medium text-gray-700 mb-1;
+  }
+  .badge-green {
+    @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800;
+  }
+  .badge-red {
+    @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800;
+  }
+  .badge-blue {
+    @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800;
+  }
+}

+ 10 - 0
frontend/src/main.jsx

@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App.jsx'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+  <React.StrictMode>
+    <App />
+  </React.StrictMode>
+)

+ 109 - 0
frontend/src/pages/Dashboard.jsx

@@ -0,0 +1,109 @@
+import { useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import api from '../api'
+
+function StatCard({ icon, label, value, sub, color = 'blue' }) {
+  const colors = {
+    blue: 'bg-blue-50 text-blue-700',
+    green: 'bg-green-50 text-green-700',
+    orange: 'bg-orange-50 text-orange-700',
+    purple: 'bg-purple-50 text-purple-700',
+  }
+  return (
+    <div className="card">
+      <div className="flex items-center gap-4">
+        <div className={`text-3xl p-3 rounded-xl ${colors[color]}`}>{icon}</div>
+        <div>
+          <p className="text-sm text-gray-500">{label}</p>
+          <p className="text-2xl font-bold text-gray-900">{value}</p>
+          {sub && <p className="text-xs text-gray-400 mt-0.5">{sub}</p>}
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export default function Dashboard() {
+  const [stats, setStats] = useState(null)
+  const [recentPayments, setRecentPayments] = useState([])
+  const [leases, setLeases] = useState([])
+
+  useEffect(() => {
+    api.get('/payments/stats').then(r => setStats(r.data))
+    api.get('/payments').then(r => setRecentPayments(r.data.slice(0, 5)))
+    api.get('/leases').then(r => setLeases(r.data.filter(l => l.active)))
+  }, [])
+
+  const fmt = v => v ? `${Number(v).toFixed(2)} €` : '0,00 €'
+  const MONTHS = ['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc']
+
+  return (
+    <div>
+      <div className="mb-8">
+        <h1 className="text-2xl font-bold text-gray-900">Tableau de bord</h1>
+        <p className="text-gray-500 mt-1">Aperçu de votre activité locative</p>
+      </div>
+
+      {/* Stats */}
+      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
+        <StatCard icon="📋" label="Baux actifs" value={stats?.active_leases ?? '—'} color="blue" />
+        <StatCard icon="💰" label="Loyers attendus/mois" value={fmt(stats?.monthly_expected)} sub="charges incluses" color="purple" />
+        <StatCard icon="✅" label="Encaissé ce mois" value={fmt(stats?.current_month_collected)} color="green" />
+        <StatCard icon="⚠️" label="Reste à encaisser" value={fmt((stats?.monthly_expected || 0) - (stats?.current_month_collected || 0))} color="orange" />
+      </div>
+
+      <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
+        {/* Baux actifs */}
+        <div className="card">
+          <div className="flex items-center justify-between mb-4">
+            <h2 className="text-lg font-semibold text-gray-900">📋 Baux actifs</h2>
+            <Link to="/leases" className="text-sm text-blue-600 hover:underline">Voir tout</Link>
+          </div>
+          {leases.length === 0 ? (
+            <p className="text-gray-400 text-sm text-center py-6">Aucun bail actif</p>
+          ) : (
+            <div className="space-y-3">
+              {leases.slice(0, 5).map(l => (
+                <Link key={l.id} to={`/leases/${l.id}`} className="flex items-center justify-between p-3 rounded-lg hover:bg-gray-50 transition-colors">
+                  <div>
+                    <p className="font-medium text-gray-900">{l.tenant_first_name} {l.tenant_last_name}</p>
+                    <p className="text-sm text-gray-500">{l.property_name}</p>
+                  </div>
+                  <div className="text-right">
+                    <p className="font-medium text-gray-900">{fmt(l.rent_amount + l.charges_amount)}</p>
+                    <p className="text-xs text-gray-400">par mois</p>
+                  </div>
+                </Link>
+              ))}
+            </div>
+          )}
+        </div>
+
+        {/* Derniers paiements */}
+        <div className="card">
+          <div className="flex items-center justify-between mb-4">
+            <h2 className="text-lg font-semibold text-gray-900">💰 Derniers paiements</h2>
+            <Link to="/payments" className="text-sm text-blue-600 hover:underline">Voir tout</Link>
+          </div>
+          {recentPayments.length === 0 ? (
+            <p className="text-gray-400 text-sm text-center py-6">Aucun paiement enregistré</p>
+          ) : (
+            <div className="space-y-3">
+              {recentPayments.map(p => (
+                <div key={p.id} className="flex items-center justify-between p-3 rounded-lg bg-gray-50">
+                  <div>
+                    <p className="font-medium text-gray-900">{p.tenant_first_name} {p.tenant_last_name}</p>
+                    <p className="text-sm text-gray-500">{MONTHS[p.period_month-1]} {p.period_year} · {p.property_name}</p>
+                  </div>
+                  <div className="text-right">
+                    <p className="font-semibold text-green-700">{fmt(p.rent_paid + p.charges_paid)}</p>
+                  </div>
+                </div>
+              ))}
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  )
+}

+ 246 - 0
frontend/src/pages/LeaseDetail.jsx

@@ -0,0 +1,246 @@
+import { useEffect, useState } from 'react'
+import { useParams, Link } from 'react-router-dom'
+import api from '../api'
+import toast from 'react-hot-toast'
+
+const MONTHS = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']
+const METHODS = [
+  { value: 'virement', label: 'Virement bancaire' },
+  { value: 'cheque', label: 'Chèque' },
+  { value: 'especes', label: 'Espèces' },
+  { value: 'prelevement', label: 'Prélèvement automatique' },
+]
+
+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">
+        <div className="flex items-center justify-between p-6 border-b">
+          <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>
+  )
+}
+
+export default function LeaseDetail() {
+  const { id } = useParams()
+  const [lease, setLease] = useState(null)
+  const [payments, setPayments] = useState([])
+  const [showModal, setShowModal] = useState(false)
+  const [loading, setLoading] = useState(false)
+
+  const now = new Date()
+  const [form, setForm] = useState({
+    period_month: now.getMonth() + 1,
+    period_year: now.getFullYear(),
+    rent_paid: '',
+    charges_paid: '',
+    payment_date: now.toISOString().slice(0, 10),
+    payment_method: 'virement',
+    notes: ''
+  })
+
+  const load = async () => {
+    const [l, p] = await Promise.all([api.get(`/leases/${id}`), api.get(`/payments?lease_id=${id}`)])
+    setLease(l.data)
+    setPayments(p.data)
+    setForm(f => ({ ...f, rent_paid: l.data.rent_amount, charges_paid: l.data.charges_amount }))
+  }
+
+  useEffect(() => { load() }, [id])
+
+  const handlePayment = async e => {
+    e.preventDefault(); setLoading(true)
+    try {
+      await api.post('/payments', { ...form, lease_id: Number(id) })
+      toast.success('Paiement enregistré !')
+      setShowModal(false); load()
+    } catch (err) { toast.error(err.response?.data?.error || 'Erreur') }
+    finally { setLoading(false) }
+  }
+
+  const handleDeletePayment = async payId => {
+    if (!confirm('Supprimer ce paiement ?')) return
+    try { await api.delete(`/payments/${payId}`); toast.success('Paiement supprimé'); load() }
+    catch { toast.error('Erreur') }
+  }
+
+  const downloadReceipt = payId => {
+    const token = localStorage.getItem('token')
+    const link = document.createElement('a')
+    link.href = `/api/receipts/${payId}`
+    link.setAttribute('download', `quittance.pdf`)
+    // Use fetch to download with auth header
+    fetch(`/api/receipts/${payId}`, { headers: { Authorization: `Bearer ${token}` } })
+      .then(r => r.blob())
+      .then(blob => {
+        const url = window.URL.createObjectURL(blob)
+        link.href = url
+        document.body.appendChild(link)
+        link.click()
+        document.body.removeChild(link)
+        window.URL.revokeObjectURL(url)
+      })
+      .catch(() => toast.error('Erreur lors du téléchargement'))
+  }
+
+  const fmt = v => `${Number(v).toFixed(2)} €`
+  const fmtDate = d => d ? new Date(d).toLocaleDateString('fr-FR') : '—'
+
+  if (!lease) return <div className="flex justify-center py-16"><div className="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600"></div></div>
+
+  return (
+    <div>
+      <div className="mb-6">
+        <Link to="/leases" className="text-blue-600 hover:underline text-sm">← Retour aux baux</Link>
+      </div>
+
+      {/* Bail info */}
+      <div className="card mb-6">
+        <div className="flex items-start justify-between">
+          <div>
+            <h1 className="text-2xl font-bold text-gray-900">
+              {lease.tenant_first_name} {lease.tenant_last_name}
+            </h1>
+            <p className="text-gray-600 mt-1">{lease.property_name}</p>
+            <p className="text-gray-500 text-sm">{lease.property_address}, {lease.property_zip} {lease.property_city}</p>
+          </div>
+          <span className={lease.active ? 'badge-green' : 'badge-red'}>{lease.active ? 'Actif' : 'Clôturé'}</span>
+        </div>
+
+        <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6 pt-6 border-t">
+          <div>
+            <p className="text-sm text-gray-500">Loyer HC</p>
+            <p className="font-semibold text-gray-900">{fmt(lease.rent_amount)}</p>
+          </div>
+          <div>
+            <p className="text-sm text-gray-500">Charges</p>
+            <p className="font-semibold text-gray-900">{fmt(lease.charges_amount)}</p>
+          </div>
+          <div>
+            <p className="text-sm text-gray-500">Total mensuel</p>
+            <p className="font-semibold text-blue-700">{fmt(lease.rent_amount + lease.charges_amount)}</p>
+          </div>
+          <div>
+            <p className="text-sm text-gray-500">Dépôt de garantie</p>
+            <p className="font-semibold text-gray-900">{fmt(lease.deposit_amount)}</p>
+          </div>
+          <div>
+            <p className="text-sm text-gray-500">Début du bail</p>
+            <p className="font-semibold text-gray-900">{fmtDate(lease.start_date)}</p>
+          </div>
+          <div>
+            <p className="text-sm text-gray-500">Fin du bail</p>
+            <p className="font-semibold text-gray-900">{fmtDate(lease.end_date)}</p>
+          </div>
+          <div>
+            <p className="text-sm text-gray-500">Échéance</p>
+            <p className="font-semibold text-gray-900">Le {lease.payment_day} du mois</p>
+          </div>
+        </div>
+      </div>
+
+      {/* Paiements */}
+      <div className="flex items-center justify-between mb-4">
+        <h2 className="text-xl font-bold text-gray-900">💰 Historique des paiements</h2>
+        {lease.active && (
+          <button onClick={() => setShowModal(true)} className="btn-primary">+ Enregistrer un paiement</button>
+        )}
+      </div>
+
+      {payments.length === 0 ? (
+        <div className="card text-center py-10">
+          <p className="text-gray-400">Aucun paiement enregistré pour ce bail.</p>
+        </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">Période</th>
+                <th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Date paiement</th>
+                <th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Mode</th>
+                <th className="text-right px-6 py-3 text-sm font-semibold text-gray-600">Loyer</th>
+                <th className="text-right px-6 py-3 text-sm font-semibold text-gray-600">Charges</th>
+                <th className="text-right px-6 py-3 text-sm font-semibold text-gray-600">Total</th>
+                <th className="px-6 py-3"></th>
+              </tr>
+            </thead>
+            <tbody className="divide-y divide-gray-50">
+              {payments.map(p => (
+                <tr key={p.id} className="hover:bg-gray-50">
+                  <td className="px-6 py-4 font-medium text-gray-900">{MONTHS[p.period_month-1]} {p.period_year}</td>
+                  <td className="px-6 py-4 text-gray-600">{fmtDate(p.payment_date)}</td>
+                  <td className="px-6 py-4 text-gray-600 capitalize">{p.payment_method}</td>
+                  <td className="px-6 py-4 text-right text-gray-900">{fmt(p.rent_paid)}</td>
+                  <td className="px-6 py-4 text-right text-gray-600">{fmt(p.charges_paid)}</td>
+                  <td className="px-6 py-4 text-right font-semibold text-green-700">{fmt(p.rent_paid + p.charges_paid)}</td>
+                  <td className="px-6 py-4 text-right space-x-2">
+                    <button onClick={() => downloadReceipt(p.id)} className="text-blue-600 hover:text-blue-800 text-sm font-medium" title="Télécharger la quittance PDF">📄 Quittance</button>
+                    <button onClick={() => handleDeletePayment(p.id)} className="text-gray-400 hover:text-red-600 ml-2">🗑️</button>
+                  </td>
+                </tr>
+              ))}
+            </tbody>
+          </table>
+        </div>
+      )}
+
+      {/* Modal paiement */}
+      {showModal && (
+        <Modal title="Enregistrer un paiement" onClose={() => setShowModal(false)}>
+          <form onSubmit={handlePayment} className="space-y-4">
+            <div className="grid grid-cols-2 gap-4">
+              <div>
+                <label className="label">Mois *</label>
+                <select className="input" value={form.period_month} onChange={e => setForm(f => ({ ...f, period_month: Number(e.target.value) }))}>
+                  {MONTHS.map((m, i) => <option key={i} value={i+1}>{m}</option>)}
+                </select>
+              </div>
+              <div>
+                <label className="label">Année *</label>
+                <input className="input" type="number" min="2000" max="2099" value={form.period_year}
+                  onChange={e => setForm(f => ({ ...f, period_year: Number(e.target.value) }))} />
+              </div>
+              <div>
+                <label className="label">Loyer encaissé (€) *</label>
+                <input className="input" type="number" step="0.01" min="0" required value={form.rent_paid}
+                  onChange={e => setForm(f => ({ ...f, rent_paid: e.target.value }))} />
+              </div>
+              <div>
+                <label className="label">Charges encaissées (€)</label>
+                <input className="input" type="number" step="0.01" min="0" value={form.charges_paid}
+                  onChange={e => setForm(f => ({ ...f, charges_paid: e.target.value }))} />
+              </div>
+              <div>
+                <label className="label">Date de paiement *</label>
+                <input className="input" type="date" required value={form.payment_date}
+                  onChange={e => setForm(f => ({ ...f, payment_date: e.target.value }))} />
+              </div>
+              <div>
+                <label className="label">Mode de paiement</label>
+                <select className="input" value={form.payment_method} onChange={e => setForm(f => ({ ...f, payment_method: e.target.value }))}>
+                  {METHODS.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
+                </select>
+              </div>
+              <div className="col-span-2">
+                <label className="label">Notes (optionnel)</label>
+                <input className="input" value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} placeholder="..." />
+              </div>
+            </div>
+            <div className="bg-blue-50 rounded-lg p-3 text-sm text-blue-700">
+              <strong>Total à encaisser :</strong> {fmt(Number(form.rent_paid || 0) + Number(form.charges_paid || 0))}
+            </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 ? '...' : 'Enregistrer'}</button>
+            </div>
+          </form>
+        </Modal>
+      )}
+    </div>
+  )
+}

+ 160 - 0
frontend/src/pages/Leases.jsx

@@ -0,0 +1,160 @@
+import { useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import api from '../api'
+import toast from 'react-hot-toast'
+
+function Modal({ title, onClose, children }) {
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 overflow-y-auto">
+      <div className="bg-white rounded-2xl shadow-xl w-full max-w-lg my-4">
+        <div className="flex items-center justify-between p-6 border-b">
+          <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>
+  )
+}
+
+const emptyForm = { property_id: '', tenant_id: '', start_date: '', end_date: '', rent_amount: '', charges_amount: '', deposit_amount: '', payment_day: '1' }
+
+export default function Leases() {
+  const [leases, setLeases] = useState([])
+  const [properties, setProperties] = useState([])
+  const [tenants, setTenants] = useState([])
+  const [showModal, setShowModal] = useState(false)
+  const [form, setForm] = useState(emptyForm)
+  const [loading, setLoading] = useState(false)
+
+  const load = () => {
+    api.get('/leases').then(r => setLeases(r.data))
+    api.get('/properties').then(r => setProperties(r.data))
+    api.get('/tenants').then(r => setTenants(r.data))
+  }
+  useEffect(() => { load() }, [])
+
+  const handleSubmit = async e => {
+    e.preventDefault(); setLoading(true)
+    try {
+      await api.post('/leases', { ...form, property_id: Number(form.property_id), tenant_id: Number(form.tenant_id) })
+      toast.success('Bail créé !')
+      setShowModal(false); load()
+    } catch (err) { toast.error(err.response?.data?.error || 'Erreur') }
+    finally { setLoading(false) }
+  }
+
+  const handleToggle = async lease => {
+    try {
+      await api.put(`/leases/${lease.id}`, {
+        end_date: lease.end_date, rent_amount: lease.rent_amount, charges_amount: lease.charges_amount,
+        deposit_amount: lease.deposit_amount, payment_day: lease.payment_day, active: lease.active ? 0 : 1
+      })
+      toast.success(lease.active ? 'Bail clôturé' : 'Bail réactivé'); load()
+    } catch { toast.error('Erreur') }
+  }
+
+  const fmt = v => `${Number(v).toFixed(2)} €`
+  const fmtDate = d => d ? new Date(d).toLocaleDateString('fr-FR') : '—'
+
+  return (
+    <div>
+      <div className="flex items-center justify-between mb-8">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">📋 Baux</h1>
+          <p className="text-gray-500 mt-1">{leases.filter(l => l.active).length} bail(s) actif(s)</p>
+        </div>
+        <button onClick={() => { setForm(emptyForm); setShowModal(true) }} className="btn-primary">+ Nouveau bail</button>
+      </div>
+
+      {leases.length === 0 ? (
+        <div className="card text-center py-16">
+          <div className="text-5xl mb-4">📋</div>
+          <p className="text-gray-500">Aucun bail. Commencez par ajouter vos biens et locataires.</p>
+        </div>
+      ) : (
+        <div className="space-y-3">
+          {leases.map(l => (
+            <div key={l.id} className={`card hover:shadow-md transition-shadow ${!l.active ? 'opacity-60' : ''}`}>
+              <div className="flex items-center justify-between">
+                <div className="flex items-center gap-4">
+                  <div>
+                    <div className="flex items-center gap-2">
+                      <h3 className="font-semibold text-gray-900">{l.tenant_first_name} {l.tenant_last_name}</h3>
+                      <span className={l.active ? 'badge-green' : 'badge-red'}>{l.active ? 'Actif' : 'Clôturé'}</span>
+                    </div>
+                    <p className="text-sm text-gray-500">{l.property_name} · {l.property_address}, {l.property_city}</p>
+                    <p className="text-sm text-gray-400">Du {fmtDate(l.start_date)}{l.end_date ? ` au ${fmtDate(l.end_date)}` : ''}</p>
+                  </div>
+                </div>
+                <div className="flex items-center gap-6">
+                  <div className="text-right">
+                    <p className="font-semibold text-gray-900">{fmt(l.rent_amount + l.charges_amount)}/mois</p>
+                    <p className="text-xs text-gray-400">Loyer {fmt(l.rent_amount)} + Charges {fmt(l.charges_amount)}</p>
+                  </div>
+                  <div className="flex gap-2">
+                    <Link to={`/leases/${l.id}`} className="btn-secondary text-sm px-3 py-1.5">Détail</Link>
+                    <button onClick={() => handleToggle(l)} className={`text-sm px-3 py-1.5 rounded-lg transition-colors ${l.active ? 'bg-red-50 text-red-600 hover:bg-red-100' : 'bg-green-50 text-green-600 hover:bg-green-100'}`}>
+                      {l.active ? 'Clôturer' : 'Réactiver'}
+                    </button>
+                  </div>
+                </div>
+              </div>
+            </div>
+          ))}
+        </div>
+      )}
+
+      {showModal && (
+        <Modal title="Nouveau bail" onClose={() => setShowModal(false)}>
+          <form onSubmit={handleSubmit} className="space-y-4">
+            <div>
+              <label className="label">Bien *</label>
+              <select className="input" required value={form.property_id} onChange={e => setForm(f => ({ ...f, property_id: e.target.value }))}>
+                <option value="">Sélectionner un bien</option>
+                {properties.map(p => <option key={p.id} value={p.id}>{p.name} — {p.city}</option>)}
+              </select>
+            </div>
+            <div>
+              <label className="label">Locataire *</label>
+              <select className="input" required value={form.tenant_id} onChange={e => setForm(f => ({ ...f, tenant_id: e.target.value }))}>
+                <option value="">Sélectionner un locataire</option>
+                {tenants.map(t => <option key={t.id} value={t.id}>{t.first_name} {t.last_name}</option>)}
+              </select>
+            </div>
+            <div className="grid grid-cols-2 gap-4">
+              <div>
+                <label className="label">Date de début *</label>
+                <input className="input" type="date" required value={form.start_date} onChange={e => setForm(f => ({ ...f, start_date: e.target.value }))} />
+              </div>
+              <div>
+                <label className="label">Date de fin</label>
+                <input className="input" type="date" value={form.end_date} onChange={e => setForm(f => ({ ...f, end_date: e.target.value }))} />
+              </div>
+              <div>
+                <label className="label">Loyer HC (€) *</label>
+                <input className="input" type="number" step="0.01" min="0" required value={form.rent_amount} onChange={e => setForm(f => ({ ...f, rent_amount: e.target.value }))} placeholder="800" />
+              </div>
+              <div>
+                <label className="label">Charges (€)</label>
+                <input className="input" type="number" step="0.01" min="0" value={form.charges_amount} onChange={e => setForm(f => ({ ...f, charges_amount: e.target.value }))} placeholder="80" />
+              </div>
+              <div>
+                <label className="label">Dépôt de garantie (€)</label>
+                <input className="input" type="number" step="0.01" min="0" value={form.deposit_amount} onChange={e => setForm(f => ({ ...f, deposit_amount: e.target.value }))} placeholder="1600" />
+              </div>
+              <div>
+                <label className="label">Jour de paiement</label>
+                <input className="input" type="number" min="1" max="28" value={form.payment_day} onChange={e => setForm(f => ({ ...f, payment_day: e.target.value }))} placeholder="1" />
+              </div>
+            </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 ? '...' : 'Créer le bail'}</button>
+            </div>
+          </form>
+        </Modal>
+      )}
+    </div>
+  )
+}

+ 54 - 0
frontend/src/pages/Login.jsx

@@ -0,0 +1,54 @@
+import { useState } from 'react'
+import { Link, useNavigate } from 'react-router-dom'
+import { useAuth } from '../context/AuthContext'
+import toast from 'react-hot-toast'
+
+export default function Login() {
+  const [form, setForm] = useState({ email: '', password: '' })
+  const [loading, setLoading] = useState(false)
+  const { login } = useAuth()
+  const navigate = useNavigate()
+
+  const handleSubmit = async e => {
+    e.preventDefault()
+    setLoading(true)
+    try {
+      await login(form.email, form.password)
+      navigate('/')
+    } catch (err) {
+      toast.error(err.response?.data?.error || 'Erreur de connexion')
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  return (
+    <div className="min-h-screen bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center p-4">
+      <div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md">
+        <div className="text-center mb-8">
+          <div className="text-5xl mb-3">🏠</div>
+          <h1 className="text-2xl font-bold text-gray-900">Gestion Locative</h1>
+          <p className="text-gray-500 mt-1">Connectez-vous à votre espace</p>
+        </div>
+        <form onSubmit={handleSubmit} className="space-y-4">
+          <div>
+            <label className="label">Email</label>
+            <input className="input" type="email" required value={form.email}
+              onChange={e => setForm(f => ({ ...f, email: e.target.value }))} placeholder="votre@email.com" />
+          </div>
+          <div>
+            <label className="label">Mot de passe</label>
+            <input className="input" type="password" required value={form.password}
+              onChange={e => setForm(f => ({ ...f, password: e.target.value }))} placeholder="••••••••" />
+          </div>
+          <button type="submit" disabled={loading} className="btn-primary w-full py-3">
+            {loading ? 'Connexion...' : 'Se connecter'}
+          </button>
+        </form>
+        <p className="text-center text-sm text-gray-500 mt-6">
+          Pas encore de compte ? <Link to="/register" className="text-blue-600 hover:underline font-medium">Créer un compte</Link>
+        </p>
+      </div>
+    </div>
+  )
+}

+ 107 - 0
frontend/src/pages/Payments.jsx

@@ -0,0 +1,107 @@
+import { useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import api from '../api'
+import toast from 'react-hot-toast'
+
+const MONTHS = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']
+
+export default function Payments() {
+  const [payments, setPayments] = useState([])
+  const [filter, setFilter] = useState('')
+
+  const load = () => api.get('/payments').then(r => setPayments(r.data))
+  useEffect(() => { load() }, [])
+
+  const handleDelete = async id => {
+    if (!confirm('Supprimer ce paiement ?')) return
+    try { await api.delete(`/payments/${id}`); toast.success('Supprimé'); load() }
+    catch { toast.error('Erreur') }
+  }
+
+  const downloadReceipt = payId => {
+    const token = localStorage.getItem('token')
+    fetch(`/api/receipts/${payId}`, { headers: { Authorization: `Bearer ${token}` } })
+      .then(r => r.blob())
+      .then(blob => {
+        const url = window.URL.createObjectURL(blob)
+        const link = document.createElement('a')
+        link.href = url
+        link.setAttribute('download', `quittance_${payId}.pdf`)
+        document.body.appendChild(link)
+        link.click()
+        document.body.removeChild(link)
+        window.URL.revokeObjectURL(url)
+      })
+      .catch(() => toast.error('Erreur téléchargement'))
+  }
+
+  const fmt = v => `${Number(v).toFixed(2)} €`
+  const fmtDate = d => d ? new Date(d).toLocaleDateString('fr-FR') : '—'
+
+  const filtered = payments.filter(p => {
+    if (!filter) return true
+    const search = filter.toLowerCase()
+    return [p.tenant_first_name, p.tenant_last_name, p.property_name].join(' ').toLowerCase().includes(search)
+  })
+
+  const total = filtered.reduce((s, p) => s + p.rent_paid + p.charges_paid, 0)
+
+  return (
+    <div>
+      <div className="flex items-center justify-between mb-8">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">💰 Paiements</h1>
+          <p className="text-gray-500 mt-1">{payments.length} paiement(s) au total</p>
+        </div>
+      </div>
+
+      <div className="flex items-center gap-4 mb-6">
+        <input className="input max-w-xs" placeholder="🔍 Rechercher..." value={filter} onChange={e => setFilter(e.target.value)} />
+        {filtered.length > 0 && (
+          <p className="text-sm text-gray-500">
+            <strong>{filtered.length}</strong> résultat(s) — Total : <strong className="text-green-700">{fmt(total)}</strong>
+          </p>
+        )}
+      </div>
+
+      {filtered.length === 0 ? (
+        <div className="card text-center py-16">
+          <div className="text-5xl mb-4">💰</div>
+          <p className="text-gray-500">Aucun paiement trouvé.</p>
+          <p className="text-sm text-gray-400 mt-2">Allez dans un bail pour enregistrer un paiement.</p>
+          <Link to="/leases" className="btn-primary inline-block mt-4">Voir les baux</Link>
+        </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">Locataire</th>
+                <th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Bien</th>
+                <th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Période</th>
+                <th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Date</th>
+                <th className="text-right px-6 py-3 text-sm font-semibold text-gray-600">Total</th>
+                <th className="px-6 py-3"></th>
+              </tr>
+            </thead>
+            <tbody className="divide-y divide-gray-50">
+              {filtered.map(p => (
+                <tr key={p.id} className="hover:bg-gray-50">
+                  <td className="px-6 py-4 font-medium text-gray-900">{p.tenant_first_name} {p.tenant_last_name}</td>
+                  <td className="px-6 py-4 text-gray-600">{p.property_name}</td>
+                  <td className="px-6 py-4 text-gray-600">{MONTHS[p.period_month-1]} {p.period_year}</td>
+                  <td className="px-6 py-4 text-gray-600">{fmtDate(p.payment_date)}</td>
+                  <td className="px-6 py-4 text-right font-semibold text-green-700">{fmt(p.rent_paid + p.charges_paid)}</td>
+                  <td className="px-6 py-4 text-right space-x-3">
+                    <button onClick={() => downloadReceipt(p.id)} className="text-blue-600 hover:text-blue-800 text-sm font-medium">📄 Quittance</button>
+                    <button onClick={() => handleDelete(p.id)} className="text-gray-400 hover:text-red-600">🗑️</button>
+                  </td>
+                </tr>
+              ))}
+            </tbody>
+          </table>
+        </div>
+      )}
+    </div>
+  )
+}

+ 140 - 0
frontend/src/pages/Properties.jsx

@@ -0,0 +1,140 @@
+import { useEffect, useState } from 'react'
+import api from '../api'
+import toast from 'react-hot-toast'
+
+const TYPES = ['Appartement', 'Maison', 'Studio', 'Chambre', 'Local commercial', 'Autre']
+
+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">
+        <div className="flex items-center justify-between p-6 border-b">
+          <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>
+  )
+}
+
+const emptyForm = { name: '', address: '', city: '', zip_code: '', type: 'Appartement', rooms: '', area: '' }
+
+export default function Properties() {
+  const [properties, setProperties] = useState([])
+  const [showModal, setShowModal] = useState(false)
+  const [form, setForm] = useState(emptyForm)
+  const [editing, setEditing] = useState(null)
+  const [loading, setLoading] = useState(false)
+
+  const load = () => api.get('/properties').then(r => setProperties(r.data))
+  useEffect(() => { load() }, [])
+
+  const openAdd = () => { setForm(emptyForm); setEditing(null); setShowModal(true) }
+  const openEdit = p => { setForm({ name: p.name, address: p.address, city: p.city, zip_code: p.zip_code, type: p.type, rooms: p.rooms || '', area: p.area || '' }); setEditing(p.id); setShowModal(true) }
+
+  const handleSubmit = async e => {
+    e.preventDefault()
+    setLoading(true)
+    try {
+      if (editing) await api.put(`/properties/${editing}`, form)
+      else await api.post('/properties', form)
+      toast.success(editing ? 'Bien modifié !' : 'Bien ajouté !')
+      setShowModal(false); load()
+    } catch (err) {
+      toast.error(err.response?.data?.error || 'Erreur')
+    } finally { setLoading(false) }
+  }
+
+  const handleDelete = async id => {
+    if (!confirm('Supprimer ce bien ?')) return
+    try { await api.delete(`/properties/${id}`); toast.success('Bien supprimé'); load() }
+    catch (err) { toast.error(err.response?.data?.error || 'Erreur') }
+  }
+
+  return (
+    <div>
+      <div className="flex items-center justify-between mb-8">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">🏠 Biens immobiliers</h1>
+          <p className="text-gray-500 mt-1">{properties.length} bien(s) enregistré(s)</p>
+        </div>
+        <button onClick={openAdd} className="btn-primary">+ Ajouter un bien</button>
+      </div>
+
+      {properties.length === 0 ? (
+        <div className="card text-center py-16">
+          <div className="text-5xl mb-4">🏠</div>
+          <p className="text-gray-500">Aucun bien enregistré. Commencez par ajouter votre premier bien !</p>
+        </div>
+      ) : (
+        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+          {properties.map(p => (
+            <div key={p.id} className="card hover:shadow-md transition-shadow">
+              <div className="flex items-start justify-between mb-3">
+                <div>
+                  <h3 className="font-semibold text-gray-900">{p.name}</h3>
+                  <span className="badge-blue mt-1">{p.type}</span>
+                </div>
+                <div className="flex gap-2">
+                  <button onClick={() => openEdit(p)} className="text-gray-400 hover:text-blue-600 text-sm">✏️</button>
+                  <button onClick={() => handleDelete(p.id)} className="text-gray-400 hover:text-red-600 text-sm">🗑️</button>
+                </div>
+              </div>
+              <p className="text-sm text-gray-600">{p.address}</p>
+              <p className="text-sm text-gray-600">{p.zip_code} {p.city}</p>
+              {(p.rooms || p.area) && (
+                <p className="text-sm text-gray-500 mt-2">
+                  {p.rooms && `${p.rooms} pièce(s)`}{p.rooms && p.area && ' · '}{p.area && `${p.area} m²`}
+                </p>
+              )}
+            </div>
+          ))}
+        </div>
+      )}
+
+      {showModal && (
+        <Modal title={editing ? 'Modifier le bien' : 'Ajouter un bien'} onClose={() => setShowModal(false)}>
+          <form onSubmit={handleSubmit} className="space-y-4">
+            <div className="grid grid-cols-2 gap-4">
+              <div className="col-span-2">
+                <label className="label">Nom du bien *</label>
+                <input className="input" required value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="ex: Appartement T3 Paris" />
+              </div>
+              <div className="col-span-2">
+                <label className="label">Adresse *</label>
+                <input className="input" required value={form.address} onChange={e => setForm(f => ({ ...f, address: e.target.value }))} placeholder="123 rue de la Paix" />
+              </div>
+              <div>
+                <label className="label">Code postal *</label>
+                <input className="input" required value={form.zip_code} onChange={e => setForm(f => ({ ...f, zip_code: e.target.value }))} placeholder="75001" />
+              </div>
+              <div>
+                <label className="label">Ville *</label>
+                <input className="input" required value={form.city} onChange={e => setForm(f => ({ ...f, city: e.target.value }))} placeholder="Paris" />
+              </div>
+              <div>
+                <label className="label">Type *</label>
+                <select className="input" value={form.type} onChange={e => setForm(f => ({ ...f, type: e.target.value }))}>
+                  {TYPES.map(t => <option key={t}>{t}</option>)}
+                </select>
+              </div>
+              <div>
+                <label className="label">Nb pièces</label>
+                <input className="input" type="number" min="1" value={form.rooms} onChange={e => setForm(f => ({ ...f, rooms: e.target.value }))} placeholder="3" />
+              </div>
+              <div>
+                <label className="label">Surface (m²)</label>
+                <input className="input" type="number" min="1" step="0.1" value={form.area} onChange={e => setForm(f => ({ ...f, area: e.target.value }))} placeholder="65" />
+              </div>
+            </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 ? '...' : 'Enregistrer'}</button>
+            </div>
+          </form>
+        </Modal>
+      )}
+    </div>
+  )
+}

+ 60 - 0
frontend/src/pages/Register.jsx

@@ -0,0 +1,60 @@
+import { useState } from 'react'
+import { Link, useNavigate } from 'react-router-dom'
+import { useAuth } from '../context/AuthContext'
+import toast from 'react-hot-toast'
+
+export default function Register() {
+  const [form, setForm] = useState({ name: '', email: '', password: '' })
+  const [loading, setLoading] = useState(false)
+  const { register } = useAuth()
+  const navigate = useNavigate()
+
+  const handleSubmit = async e => {
+    e.preventDefault()
+    setLoading(true)
+    try {
+      await register(form.email, form.password, form.name)
+      navigate('/')
+      toast.success('Compte créé avec succès !')
+    } catch (err) {
+      toast.error(err.response?.data?.error || 'Erreur lors de la création du compte')
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  return (
+    <div className="min-h-screen bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center p-4">
+      <div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md">
+        <div className="text-center mb-8">
+          <div className="text-5xl mb-3">🏠</div>
+          <h1 className="text-2xl font-bold text-gray-900">Créer un compte</h1>
+          <p className="text-gray-500 mt-1">Gérez vos locations en toute simplicité</p>
+        </div>
+        <form onSubmit={handleSubmit} className="space-y-4">
+          <div>
+            <label className="label">Votre nom complet</label>
+            <input className="input" type="text" required value={form.name}
+              onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="Jean Dupont" />
+          </div>
+          <div>
+            <label className="label">Email</label>
+            <input className="input" type="email" required value={form.email}
+              onChange={e => setForm(f => ({ ...f, email: e.target.value }))} placeholder="votre@email.com" />
+          </div>
+          <div>
+            <label className="label">Mot de passe</label>
+            <input className="input" type="password" required minLength={6} value={form.password}
+              onChange={e => setForm(f => ({ ...f, password: e.target.value }))} placeholder="Minimum 6 caractères" />
+          </div>
+          <button type="submit" disabled={loading} className="btn-primary w-full py-3">
+            {loading ? 'Création...' : 'Créer mon compte'}
+          </button>
+        </form>
+        <p className="text-center text-sm text-gray-500 mt-6">
+          Déjà un compte ? <Link to="/login" className="text-blue-600 hover:underline font-medium">Se connecter</Link>
+        </p>
+      </div>
+    </div>
+  )
+}

+ 127 - 0
frontend/src/pages/Tenants.jsx

@@ -0,0 +1,127 @@
+import { useEffect, useState } from 'react'
+import api from '../api'
+import toast from 'react-hot-toast'
+
+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">
+        <div className="flex items-center justify-between p-6 border-b">
+          <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>
+  )
+}
+
+const emptyForm = { first_name: '', last_name: '', email: '', phone: '' }
+
+export default function Tenants() {
+  const [tenants, setTenants] = useState([])
+  const [showModal, setShowModal] = useState(false)
+  const [form, setForm] = useState(emptyForm)
+  const [editing, setEditing] = useState(null)
+  const [loading, setLoading] = useState(false)
+
+  const load = () => api.get('/tenants').then(r => setTenants(r.data))
+  useEffect(() => { load() }, [])
+
+  const openAdd = () => { setForm(emptyForm); setEditing(null); setShowModal(true) }
+  const openEdit = t => {
+    setForm({ first_name: t.first_name, last_name: t.last_name, email: t.email || '', phone: t.phone || '' })
+    setEditing(t.id); setShowModal(true)
+  }
+
+  const handleSubmit = async e => {
+    e.preventDefault(); setLoading(true)
+    try {
+      if (editing) await api.put(`/tenants/${editing}`, form)
+      else await api.post('/tenants', form)
+      toast.success(editing ? 'Locataire modifié !' : 'Locataire ajouté !')
+      setShowModal(false); load()
+    } catch (err) { toast.error(err.response?.data?.error || 'Erreur') }
+    finally { setLoading(false) }
+  }
+
+  const handleDelete = async id => {
+    if (!confirm('Supprimer ce locataire ?')) return
+    try { await api.delete(`/tenants/${id}`); toast.success('Locataire supprimé'); load() }
+    catch (err) { toast.error(err.response?.data?.error || 'Erreur') }
+  }
+
+  return (
+    <div>
+      <div className="flex items-center justify-between mb-8">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">👤 Locataires</h1>
+          <p className="text-gray-500 mt-1">{tenants.length} locataire(s) enregistré(s)</p>
+        </div>
+        <button onClick={openAdd} className="btn-primary">+ Ajouter un locataire</button>
+      </div>
+
+      {tenants.length === 0 ? (
+        <div className="card text-center py-16">
+          <div className="text-5xl mb-4">👤</div>
+          <p className="text-gray-500">Aucun locataire enregistré.</p>
+        </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">Nom</th>
+                <th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Email</th>
+                <th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Téléphone</th>
+                <th className="px-6 py-3"></th>
+              </tr>
+            </thead>
+            <tbody className="divide-y divide-gray-50">
+              {tenants.map(t => (
+                <tr key={t.id} className="hover:bg-gray-50">
+                  <td className="px-6 py-4 font-medium text-gray-900">{t.first_name} {t.last_name}</td>
+                  <td className="px-6 py-4 text-gray-600">{t.email || '—'}</td>
+                  <td className="px-6 py-4 text-gray-600">{t.phone || '—'}</td>
+                  <td className="px-6 py-4 text-right space-x-3">
+                    <button onClick={() => openEdit(t)} className="text-gray-400 hover:text-blue-600">✏️</button>
+                    <button onClick={() => handleDelete(t.id)} className="text-gray-400 hover:text-red-600">🗑️</button>
+                  </td>
+                </tr>
+              ))}
+            </tbody>
+          </table>
+        </div>
+      )}
+
+      {showModal && (
+        <Modal title={editing ? 'Modifier le locataire' : 'Ajouter un locataire'} onClose={() => setShowModal(false)}>
+          <form onSubmit={handleSubmit} className="space-y-4">
+            <div className="grid grid-cols-2 gap-4">
+              <div>
+                <label className="label">Prénom *</label>
+                <input className="input" required value={form.first_name} onChange={e => setForm(f => ({ ...f, first_name: e.target.value }))} />
+              </div>
+              <div>
+                <label className="label">Nom *</label>
+                <input className="input" required value={form.last_name} onChange={e => setForm(f => ({ ...f, last_name: e.target.value }))} />
+              </div>
+              <div className="col-span-2">
+                <label className="label">Email</label>
+                <input className="input" type="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} />
+              </div>
+              <div className="col-span-2">
+                <label className="label">Téléphone</label>
+                <input className="input" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} />
+              </div>
+            </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 ? '...' : 'Enregistrer'}</button>
+            </div>
+          </form>
+        </Modal>
+      )}
+    </div>
+  )
+}

+ 18 - 0
frontend/tailwind.config.js

@@ -0,0 +1,18 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+  content: ['./index.html', './src/**/*.{js,jsx}'],
+  theme: {
+    extend: {
+      colors: {
+        primary: {
+          50: '#eff6ff',
+          100: '#dbeafe',
+          500: '#3b82f6',
+          600: '#2563eb',
+          700: '#1d4ed8',
+        }
+      }
+    }
+  },
+  plugins: []
+}

+ 15 - 0
frontend/vite.config.js

@@ -0,0 +1,15 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+  plugins: [react()],
+  server: {
+    port: 3000,
+    proxy: {
+      '/api': {
+        target: 'http://localhost:3001',
+        changeOrigin: true
+      }
+    }
+  }
+})