jeremy 4 месяцев назад
Родитель
Сommit
37b0fd96a3
3 измененных файлов с 192 добавлено и 2 удалено
  1. 16 0
      backend/src/db.js
  2. 45 0
      backend/src/routes/leases.js
  3. 131 2
      frontend/src/pages/LeaseDetail.jsx

+ 16 - 0
backend/src/db.js

@@ -97,4 +97,20 @@ db.exec(`
   try { db.exec(`ALTER TABLE users ADD COLUMN ${col} TEXT`); } catch {}
 });
 
+// Lease revisions history
+db.exec(`
+  CREATE TABLE IF NOT EXISTS lease_revisions (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    lease_id INTEGER NOT NULL,
+    user_id INTEGER NOT NULL,
+    effective_date TEXT NOT NULL,
+    rent_amount REAL NOT NULL,
+    charges_amount REAL NOT NULL DEFAULT 0,
+    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;

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

@@ -65,4 +65,49 @@ router.delete('/:id', (req, res) => {
   res.json({ success: true });
 });
 
+// --- Révisions de loyer ---
+
+// List revisions for a lease
+router.get('/:id/revisions', (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 rows = db.prepare(
+    'SELECT * FROM lease_revisions WHERE lease_id = ? ORDER BY effective_date DESC'
+  ).all(req.params.id);
+  res.json(rows);
+});
+
+// Create revision (and update lease current amounts)
+router.post('/:id/revisions', (req, res) => {
+  const lease = db.prepare('SELECT * 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 { effective_date, rent_amount, charges_amount, notes } = req.body;
+  if (!effective_date || rent_amount === undefined)
+    return res.status(400).json({ error: 'Date d\'effet et nouveau loyer requis' });
+
+  // Save revision history entry
+  const result = db.prepare(`
+    INSERT INTO lease_revisions (lease_id, user_id, effective_date, rent_amount, charges_amount, notes)
+    VALUES (?, ?, ?, ?, ?, ?)
+  `).run(req.params.id, req.userId, effective_date, parseFloat(rent_amount), parseFloat(charges_amount || 0), notes || null);
+
+  // If effective_date <= today, apply immediately to lease
+  const today = new Date().toISOString().slice(0, 10);
+  if (effective_date <= today) {
+    db.prepare('UPDATE leases SET rent_amount=?, charges_amount=? WHERE id=?')
+      .run(parseFloat(rent_amount), parseFloat(charges_amount || 0), req.params.id);
+  }
+
+  res.status(201).json({ id: result.lastInsertRowid });
+});
+
+// Delete revision
+router.delete('/:id/revisions/:revId', (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 lease_revisions WHERE id = ? AND lease_id = ?').run(req.params.revId, req.params.id);
+  res.json({ success: true });
+});
+
 module.exports = router;

+ 131 - 2
frontend/src/pages/LeaseDetail.jsx

@@ -29,8 +29,11 @@ export default function LeaseDetail() {
   const { id } = useParams()
   const [lease, setLease] = useState(null)
   const [payments, setPayments] = useState([])
+  const [revisions, setRevisions] = useState([])
   const [showModal, setShowModal] = useState(false)
+  const [showRevisionModal, setShowRevisionModal] = useState(false)
   const [loading, setLoading] = useState(false)
+  const [revLoading, setRevLoading] = useState(false)
 
   const now = new Date()
   const [form, setForm] = useState({
@@ -43,10 +46,22 @@ export default function LeaseDetail() {
     notes: ''
   })
 
+  const [revForm, setRevForm] = useState({
+    effective_date: now.toISOString().slice(0, 10),
+    rent_amount: '',
+    charges_amount: '',
+    notes: ''
+  })
+
   const load = async () => {
-    const [l, p] = await Promise.all([api.get(`/leases/${id}`), api.get(`/payments?lease_id=${id}`)])
+    const [l, p, r] = await Promise.all([
+      api.get(`/leases/${id}`),
+      api.get(`/payments?lease_id=${id}`),
+      api.get(`/leases/${id}/revisions`)
+    ])
     setLease(l.data)
     setPayments(p.data)
+    setRevisions(r.data)
     setForm(f => ({ ...f, rent_paid: l.data.rent_amount, charges_paid: l.data.charges_amount }))
   }
 
@@ -68,6 +83,23 @@ export default function LeaseDetail() {
     catch { toast.error('Erreur') }
   }
 
+  const handleRevision = async e => {
+    e.preventDefault(); setRevLoading(true)
+    try {
+      await api.post(`/leases/${id}/revisions`, revForm)
+      toast.success('Révision enregistrée !')
+      setShowRevisionModal(false)
+      load()
+    } catch (err) { toast.error(err.response?.data?.error || 'Erreur') }
+    finally { setRevLoading(false) }
+  }
+
+  const handleDeleteRevision = async revId => {
+    if (!confirm('Supprimer cette révision ?')) return
+    try { await api.delete(`/leases/${id}/revisions/${revId}`); toast.success('Révision supprimée'); load() }
+    catch { toast.error('Erreur') }
+  }
+
   const downloadReceipt = payId => {
     const token = localStorage.getItem('token')
     const link = document.createElement('a')
@@ -108,7 +140,15 @@ export default function LeaseDetail() {
             <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 className="flex items-center gap-3">
+            {lease.active && (
+              <button onClick={() => {
+                setRevForm({ effective_date: now.toISOString().slice(0, 10), rent_amount: String(lease.rent_amount), charges_amount: String(lease.charges_amount), notes: '' })
+                setShowRevisionModal(true)
+              }} className="btn-secondary text-sm">📈 Réviser le loyer</button>
+            )}
+            <span className={lease.active ? 'badge-green' : 'badge-red'}>{lease.active ? 'Actif' : 'Clôturé'}</span>
+          </div>
         </div>
 
         <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6 pt-6 border-t">
@@ -143,6 +183,44 @@ export default function LeaseDetail() {
         </div>
       </div>
 
+      {/* Historique des révisions */}
+      {revisions.length > 0 && (
+        <div className="card mb-6">
+          <h2 className="text-base font-semibold text-gray-800 mb-3">📈 Historique des révisions de loyer</h2>
+          <div className="divide-y divide-gray-50">
+            {revisions.map((r, i) => {
+              const isNext = r.effective_date > now.toISOString().slice(0, 10)
+              return (
+                <div key={r.id} className="flex items-center justify-between py-3">
+                  <div className="flex items-center gap-3">
+                    {i === 0 && !isNext && (
+                      <span className="text-xs bg-green-100 text-green-700 font-semibold px-2 py-0.5 rounded-full">En vigueur</span>
+                    )}
+                    {isNext && (
+                      <span className="text-xs bg-blue-100 text-blue-700 font-semibold px-2 py-0.5 rounded-full">À venir</span>
+                    )}
+                    {i > 0 && !isNext && (
+                      <span className="text-xs bg-gray-100 text-gray-500 font-semibold px-2 py-0.5 rounded-full">Ancien</span>
+                    )}
+                    <div>
+                      <p className="text-sm font-medium text-gray-900">
+                        Loyer {fmt(r.rent_amount)} + charges {fmt(r.charges_amount)}
+                        <span className="text-gray-400 font-normal ml-1">= {fmt(r.rent_amount + r.charges_amount)}/mois</span>
+                      </p>
+                      <p className="text-xs text-gray-500">
+                        Applicable à partir du {fmtDate(r.effective_date)}
+                        {r.notes && ` — ${r.notes}`}
+                      </p>
+                    </div>
+                  </div>
+                  <button onClick={() => handleDeleteRevision(r.id)} className="text-gray-300 hover:text-red-500 ml-4">🗑️</button>
+                </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>
@@ -241,6 +319,57 @@ export default function LeaseDetail() {
           </form>
         </Modal>
       )}
+
+      {/* Modal révision loyer */}
+      {showRevisionModal && (
+        <Modal title="📈 Réviser le loyer" onClose={() => setShowRevisionModal(false)}>
+          <form onSubmit={handleRevision} className="space-y-4">
+            <div>
+              <label className="label">Date d'effet de la révision *</label>
+              <input className="input" type="date" required value={revForm.effective_date}
+                onChange={e => setRevForm(f => ({ ...f, effective_date: e.target.value }))} />
+              <p className="text-xs text-gray-400 mt-1">
+                Si la date est aujourd'hui ou passée, le loyer en vigueur sera mis à jour immédiatement.
+              </p>
+            </div>
+            <div className="grid grid-cols-2 gap-4">
+              <div>
+                <label className="label">Nouveau loyer HC (€) *</label>
+                <input className="input" type="number" step="0.01" min="0" required value={revForm.rent_amount}
+                  onChange={e => setRevForm(f => ({ ...f, rent_amount: e.target.value }))} />
+              </div>
+              <div>
+                <label className="label">Nouvelles charges (€)</label>
+                <input className="input" type="number" step="0.01" min="0" value={revForm.charges_amount}
+                  onChange={e => setRevForm(f => ({ ...f, charges_amount: e.target.value }))} />
+              </div>
+            </div>
+            {revForm.rent_amount && (
+              <div className="bg-blue-50 rounded-lg p-3 text-sm text-blue-800">
+                <strong>Nouveau total mensuel :</strong> {fmt(Number(revForm.rent_amount || 0) + Number(revForm.charges_amount || 0))}
+                {lease && (
+                  <span className="ml-2 text-blue-500">
+                    ({Number(revForm.rent_amount) - lease.rent_amount >= 0 ? '+' : ''}{fmt(Number(revForm.rent_amount) - lease.rent_amount)} loyer,{' '}
+                    {Number(revForm.charges_amount) - lease.charges_amount >= 0 ? '+' : ''}{fmt(Number(revForm.charges_amount) - lease.charges_amount)} charges)
+                  </span>
+                )}
+              </div>
+            )}
+            <div>
+              <label className="label">Notes (IRL, motif…)</label>
+              <input className="input" value={revForm.notes}
+                onChange={e => setRevForm(f => ({ ...f, notes: e.target.value }))}
+                placeholder="Ex : Révision IRL T3 2024, indice 142.6" />
+            </div>
+            <div className="flex gap-3 pt-2">
+              <button type="button" onClick={() => setShowRevisionModal(false)} className="btn-secondary flex-1">Annuler</button>
+              <button type="submit" disabled={revLoading} className="btn-primary flex-1">
+                {revLoading ? '...' : 'Enregistrer la révision'}
+              </button>
+            </div>
+          </form>
+        </Modal>
+      )}
     </div>
   )
 }