jeremy 4 月之前
父節點
當前提交
97da8ffdc9
共有 2 個文件被更改,包括 112 次插入102 次删除
  1. 77 72
      backend/src/routes/charges.js
  2. 35 30
      frontend/src/pages/Charges.jsx

+ 77 - 72
backend/src/routes/charges.js

@@ -284,23 +284,24 @@ router.get('/bilan/:property_id/:year', async (req, res) => {
   });
 });
 
-// Export PDF bilan + invoices
+// Export PDF bilan per lease (lease_id required as query param)
 router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
   const { property_id, year } = req.params;
+  const leaseId = req.query.lease_id ? parseInt(req.query.lease_id, 10) : null;
+  if (!leaseId) return res.status(400).json({ error: 'lease_id requis' });
 
   const prop = (await pool.query('SELECT * FROM properties WHERE id = $1 AND user_id = $2', [property_id, req.userId])).rows[0];
   if (!prop) return res.status(403).json({ error: 'Bien non autorisé' });
 
-  // Chercher le propriétaire lié au bail actif sur ce bien, sinon fallback profil gestionnaire
+  // Owner info: prefer the owner linked to this specific lease, fallback to manager profile
   const ownerRow = (await pool.query(`
     SELECT o.type as o_type, o.first_name, o.last_name, o.company_name, o.siret,
            o.address, o.zip_code, o.city, o.email, o.phone
     FROM leases l
     JOIN owners o ON l.owner_id = o.id
-    WHERE l.property_id = $1 AND l.user_id = $2 AND o.id IS NOT NULL
-    ORDER BY l.active DESC, l.start_date DESC
+    WHERE l.id = $1 AND l.user_id = $2 AND o.id IS NOT NULL
     LIMIT 1
-  `, [property_id, req.userId])).rows[0];
+  `, [leaseId, req.userId])).rows[0];
 
   let ownerFullName, ownerAddress, ownerEmail, ownerPhone, ownerSiret;
   if (ownerRow) {
@@ -343,22 +344,22 @@ router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
     userId: req.userId,
   });
 
-  const totalProvisions = leaseBilans.reduce((s, l) => s + l.provisions_percues, 0);
-  const bilans = leaseBilans.map(l => {
-    const quote_part = totalChargesReelles * l.occupancy_ratio;
-    const trop_percu = l.provisions_percues - quote_part;
-    return {
-      ...l,
-      ratio_percent: l.occupancy_ratio_percent,
-      quote_part_charges: round2(quote_part),
-      trop_percu: round2(trop_percu)
-    };
-  });
+  const leaseBilan = leaseBilans.find(l => l.lease_id === leaseId);
+  if (!leaseBilan) return res.status(404).json({ error: 'Bail non trouvé pour ce bien/période.' });
+
+  const quote_part = totalChargesReelles * leaseBilan.occupancy_ratio;
+  const trop_percu = round2(leaseBilan.provisions_percues - quote_part);
+  const bilan = {
+    ...leaseBilan,
+    ratio_percent: leaseBilan.occupancy_ratio_percent,
+    quote_part_charges: round2(quote_part),
+    trop_percu,
+  };
 
   const catLabels = { eau: 'Eau', gaz: 'Gaz', electricite: 'Électricité', entretien: 'Entretien / Réparations', ascenseur: 'Ascenseur', ordures: 'Ordures ménagères', assurance: 'Assurance', autre: 'Autre' };
   const fmtEur = v => `${Number(v).toFixed(2)} EUR`;
   const fmtDate = d => d ? new Date(d + 'T00:00:00').toLocaleDateString('fr-FR') : '—';
-  const solde = totalProvisions - totalChargesReelles;
+  const isPos = trop_percu >= 0;
 
   try {
     // --- Step 1: Generate bilan PDF with pdfkit ---
@@ -371,50 +372,83 @@ router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
 
       const W = doc.page.width - 100;
       const blue = '#2563eb'; const red = '#dc2626'; const green = '#15803d'; const orange = '#c2410c'; const gray = '#6b7280';
+      const accentColor = isPos ? green : orange;
 
       // Header
-      doc.fontSize(20).fillColor('#111827').text(`Bilan annuel de charges ${year}`, { align: 'left' });
+      doc.fontSize(20).fillColor('#111827').text(`Décompte de charges ${year}`, { align: 'left' });
       doc.fontSize(11).fillColor(gray).text(`${prop.name} — ${prop.address || ''}`, { align: 'left' });
       doc.moveDown(0.5);
       doc.fontSize(10).fillColor(gray).text(`Document généré le ${new Date().toLocaleDateString('fr-FR')}`, { align: 'right' });
       doc.moveDown(0.8);
 
-      // Bailleur info
+      // Bailleur / Locataire info side by side
       doc.moveTo(50, doc.y).lineTo(50 + W, doc.y).strokeColor('#e5e7eb').lineWidth(1).stroke();
       doc.moveDown(0.5);
-      doc.fontSize(9).fillColor(gray).text('BAILLEUR', { continued: false });
-      doc.fontSize(10).fillColor('#111827').font('Helvetica-Bold').text(ownerFullName);
+      const halfW = (W - 20) / 2;
+      const infoY = doc.y;
+
+      doc.fontSize(9).fillColor(gray).text('BAILLEUR', 50, infoY);
+      doc.fontSize(10).fillColor('#111827').font('Helvetica-Bold').text(ownerFullName, 50, infoY + 12);
       doc.font('Helvetica');
-      if (ownerSiret) doc.fontSize(10).fillColor('#374151').text(`SIRET : ${ownerSiret}`);
-      if (ownerAddress) doc.fontSize(10).fillColor('#374151').text(ownerAddress);
-      if (ownerEmail) doc.fontSize(10).fillColor('#374151').text(ownerEmail);
-      if (ownerPhone) doc.fontSize(10).fillColor('#374151').text(ownerPhone);
-      doc.moveDown(0.5);
+      let bY = infoY + 24;
+      if (ownerSiret) { doc.fontSize(9).fillColor('#374151').text(`SIRET : ${ownerSiret}`, 50, bY); bY += 12; }
+      if (ownerAddress) { doc.fontSize(9).fillColor('#374151').text(ownerAddress, 50, bY); bY += 12; }
+      if (ownerEmail) { doc.fontSize(9).fillColor('#374151').text(ownerEmail, 50, bY); bY += 12; }
+      if (ownerPhone) { doc.fontSize(9).fillColor('#374151').text(ownerPhone, 50, bY); bY += 12; }
+
+      const tenantX = 50 + halfW + 20;
+      doc.fontSize(9).fillColor(gray).text('LOCATAIRE', tenantX, infoY);
+      doc.fontSize(10).fillColor('#111827').font('Helvetica-Bold').text(bilan.tenant_name, tenantX, infoY + 12);
+      doc.font('Helvetica');
+      if (bilan.tenant_email) doc.fontSize(9).fillColor('#374151').text(bilan.tenant_email, tenantX, infoY + 24);
+
+      doc.y = Math.max(bY, infoY + 36) + 8;
       doc.moveTo(50, doc.y).lineTo(50 + W, doc.y).strokeColor('#e5e7eb').lineWidth(1).stroke();
       doc.moveDown(1);
 
-      // Summary boxes
-      doc.fontSize(11).fillColor('#111827').text('Synthèse', { underline: true });
+      // Occupancy info
+      doc.fontSize(10).fillColor(gray).text(
+        `Période d'occupation prise en compte : ${bilan.occupied_days} jour${bilan.occupied_days > 1 ? 's' : ''} sur ${bilan.year_days} (${Number(bilan.ratio_percent || 0).toFixed(2)}%)`,
+        { align: 'left' }
+      );
+      doc.moveDown(1);
+
+      // Tenant summary boxes
+      doc.fontSize(11).fillColor('#111827').text('Décompte individuel', { underline: true });
       doc.moveDown(0.5);
       const bx = 50; const bw = (W - 20) / 3;
-      [[`Charges réelles`, fmtEur(totalChargesReelles), `${totalChargesRow?.count || 0} facture(s)`, red],
-       [`Provisions perçues`, fmtEur(totalProvisions), 'de tous les locataires', blue],
-       [solde >= 0 ? 'Trop-perçu global' : 'Solde insuffisant', `${solde >= 0 ? '+' : '-'}${fmtEur(Math.abs(solde))}`, solde >= 0 ? 'à restituer' : 'à appeler', solde >= 0 ? green : orange]
+      [
+        ['Provisions perçues', fmtEur(bilan.provisions_percues), `sur l'exercice ${year}`, blue],
+        [`Quote-part charges\n(prorata ${Number(bilan.ratio_percent).toFixed(2)}%)`, fmtEur(bilan.quote_part_charges), `sur ${fmtEur(totalChargesReelles)} total`, red],
+        [isPos ? 'Trop-perçu à restituer' : 'Solde restant dû', fmtEur(Math.abs(trop_percu)), isPos ? 'En votre faveur' : 'À votre charge', accentColor],
       ].forEach(([label, val, note, color], i) => {
         const x = bx + i * (bw + 10); const y = doc.y;
-        doc.rect(x, y, bw, 60).stroke('#e5e7eb');
-        doc.fontSize(9).fillColor(gray).text(label, x + 8, y + 8, { width: bw - 16 });
-        doc.fontSize(14).fillColor(color).text(val, x + 8, y + 22, { width: bw - 16 });
-        doc.fontSize(8).fillColor(gray).text(note, x + 8, y + 42, { width: bw - 16 });
+        doc.rect(x, y, bw, 68).stroke('#e5e7eb');
+        doc.fontSize(8).fillColor(gray).text(label, x + 8, y + 8, { width: bw - 16 });
+        doc.fontSize(13).fillColor(color).text(val, x + 8, y + 28, { width: bw - 16 });
+        doc.fontSize(8).fillColor(gray).text(note, x + 8, y + 50, { width: bw - 16 });
       });
-      doc.moveDown(4.5);
+      doc.moveDown(5);
+
+      // Conclusion banner
+      const bannerY = doc.y;
+      doc.rect(50, bannerY, W, 36).fillAndStroke(isPos ? '#f0fdf4' : '#fff7ed', isPos ? '#bbf7d0' : '#fed7aa');
+      doc.fontSize(11).fillColor(accentColor).font('Helvetica-Bold').text(
+        isPos
+          ? `Montant à vous restituer : ${fmtEur(Math.abs(trop_percu))}`
+          : `Montant à régler : ${fmtEur(Math.abs(trop_percu))}`,
+        62, bannerY + 12, { width: W - 24 }
+      );
+      doc.font('Helvetica');
+      doc.y = bannerY + 46;
+      doc.moveDown(1.5);
 
       // Charges by category
       if (chargesByCategory.length > 0) {
-        doc.fontSize(11).fillColor('#111827').text('Répartition par catégorie', { underline: true });
+        doc.fontSize(11).fillColor('#111827').text('Répartition des charges par catégorie', { underline: true });
         doc.moveDown(0.5);
         const colWidths = [220, 100, 80];
-        const headers = ['Catégorie', 'Montant', '%'];
+        const headers = ['Catégorie', 'Montant total', '%'];
         let tx = 50; let ty = doc.y;
         doc.fontSize(9).fillColor(gray);
         headers.forEach((h, i) => { doc.text(h, tx, ty, { width: colWidths[i] }); tx += colWidths[i]; });
@@ -435,37 +469,10 @@ router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
         doc.moveDown(1.5);
       }
 
-      // Per tenant bilan
-      if (bilans.length > 0) {
-        doc.fontSize(11).fillColor('#111827').text('Décompte par locataire', { underline: true });
-        doc.moveDown(0.5);
-        bilans.forEach(b => {
-          const isPos = b.trop_percu >= 0;
-          const color = isPos ? green : orange;
-          const bY = doc.y;
-          doc.rect(50, bY, W, 90).stroke('#e5e7eb');
-          doc.fontSize(11).fillColor('#111827').font('Helvetica-Bold').text(b.tenant_name, 62, bY + 10, { width: W - 24 });
-          doc.font('Helvetica');
-          if (b.tenant_email) doc.fontSize(9).fillColor(gray).text(b.tenant_email, 62, bY + 24);
-          const badge = isPos ? 'Remboursement' : 'Appel de fonds';
-          doc.fontSize(9).fillColor(color).text(badge, 62, bY + 38);
-          const cols = [['Provisions perçues', fmtEur(b.provisions_percues)], [`Quote-part (${b.ratio_percent}%)`, fmtEur(b.quote_part_charges)], [isPos ? 'Trop-perçu' : 'Solde dû', fmtEur(Math.abs(b.trop_percu))]];
-          const cw = (W - 24) / 3;
-          cols.forEach(([lbl, val], i) => {
-            const cx = 62 + i * (cw + 8);
-            doc.fontSize(8).fillColor(gray).text(lbl, cx, bY + 54, { width: cw });
-            doc.fontSize(10).fillColor(i === 2 ? color : '#111827').font('Helvetica-Bold').text(val, cx, bY + 66, { width: cw });
-            doc.font('Helvetica');
-          });
-          doc.y = bY + 100;
-          doc.moveDown(0.3);
-        });
-      }
-
-      // Charges list
+      // Charges list (detail)
       if (chargesList.length > 0) {
         doc.addPage();
-        doc.fontSize(13).fillColor('#111827').text(`Détail des charges ${year}`, { underline: true });
+        doc.fontSize(13).fillColor('#111827').text(`Détail des charges récupérables ${year}`, { underline: true });
         doc.moveDown(0.5);
         const hdrs = ['Date', 'Catégorie', 'Libellé', 'Montant', 'Facture'];
         const cws2 = [70, 100, 180, 80, 100];
@@ -479,7 +486,7 @@ router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
           if (doc.y > doc.page.height - 80) { doc.addPage(); }
           hx = 50; const row = doc.y;
           doc.fontSize(9).fillColor('#111827');
-          [fmtDate(c.date), catLabels[c.category] || c.category, c.label, fmtEur(c.amount), c.invoice_name ? `📎 Voir annexe` : '—'].forEach((v, i) => {
+          [fmtDate(c.date), catLabels[c.category] || c.category, c.label, fmtEur(c.amount), c.invoice_name ? '📎 Voir annexe' : '—'].forEach((v, i) => {
             doc.text(v, hx, row, { width: cws2[i] }); hx += cws2[i];
           });
           doc.moveDown(0.5);
@@ -492,12 +499,10 @@ router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
     // --- Step 2: Merge bilan + invoice files with pdf-lib ---
     const mergedPdf = await LibPDFDocument.create();
 
-    // Copy bilan pages
     const bilanPdf = await LibPDFDocument.load(bilanBuf);
     const bilanPages = await mergedPdf.copyPages(bilanPdf, bilanPdf.getPageIndices());
     bilanPages.forEach(p => mergedPdf.addPage(p));
 
-    // Append each invoice
     for (const charge of chargesList) {
       if (!charge.invoice_path) continue;
       const filePath = path.join(uploadDir, charge.invoice_path);
@@ -524,11 +529,11 @@ router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
           page.drawImage(img, { x: 0, y: 0, width: img.width, height: img.height });
         } catch { /* skip unreadable images */ }
       }
-      // webp not supported by pdf-lib natively — skipped
     }
 
     const pdfBytes = await mergedPdf.save();
-    const filename = `bilan_charges_${year}_${prop.name.replace(/[^a-z0-9]/gi, '_')}.pdf`;
+    const tenantSlug = bilan.tenant_name.replace(/[^a-z0-9]/gi, '_');
+    const filename = `decompte_charges_${year}_${tenantSlug}.pdf`;
     res.setHeader('Content-Type', 'application/pdf');
     res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
     res.send(Buffer.from(pdfBytes));

+ 35 - 30
frontend/src/pages/Charges.jsx

@@ -46,7 +46,7 @@ export default function Charges() {
   const [showBilan, setShowBilan] = useState(false)
   const [bilan, setBilan] = useState(null)
   const [bilanLoading, setBilanLoading] = useState(false)
-  const [pdfLoading, setPdfLoading] = useState(false)
+  const [pdfLoadingLeaseId, setPdfLoadingLeaseId] = useState(null)
   const [closingLeaseId, setClosingLeaseId] = useState(null)
   const [cancelingLeaseId, setCancelingLeaseId] = useState(null)
   const [loading, setLoading] = useState(false)
@@ -296,12 +296,12 @@ export default function Charges() {
     w.document.close()
   }
 
-  const downloadPdf = async () => {
-    if (!filterProperty || !filterYear) return
-    setPdfLoading(true)
+  const downloadPdfLease = async (leaseBilan) => {
+    if (!filterProperty || !filterYear || !leaseBilan?.lease_id) return
+    setPdfLoadingLeaseId(leaseBilan.lease_id)
     try {
       const token = localStorage.getItem('token')
-      const resp = await fetch(`/api/charges/bilan/${filterProperty}/${filterYear}/pdf`, {
+      const resp = await fetch(`/api/charges/bilan/${filterProperty}/${filterYear}/pdf?lease_id=${leaseBilan.lease_id}`, {
         headers: { Authorization: `Bearer ${token}` }
       })
       if (!resp.ok) throw new Error()
@@ -309,12 +309,12 @@ export default function Charges() {
       const url = window.URL.createObjectURL(blob)
       const a = document.createElement('a')
       a.href = url
-      const propName = properties.find(p => String(p.id) === filterProperty)?.name || 'bien'
-      a.download = `bilan_charges_${filterYear}_${propName}.pdf`
+      const tenantSlug = leaseBilan.tenant_name.replace(/[^a-z0-9]/gi, '_')
+      a.download = `decompte_charges_${filterYear}_${tenantSlug}.pdf`
       a.click()
       window.URL.revokeObjectURL(url)
     } catch { toast.error('Erreur lors de la génération du PDF') }
-    finally { setPdfLoading(false) }
+    finally { setPdfLoadingLeaseId(null) }
   }
 
   const cancelLeaseExercise = async (leaseBilan) => {
@@ -616,10 +616,6 @@ export default function Charges() {
                 className="btn-secondary flex items-center gap-2 text-sm">
                 🖨️ Aperçu / Imprimer
               </button>
-              <button onClick={downloadPdf} disabled={pdfLoading}
-                className="btn-secondary flex items-center gap-2 text-sm">
-                {pdfLoading ? '⏳ Génération…' : '📥 PDF complet'}
-              </button>
             </div>
             {/* Summary cards */}
             <div className="grid grid-cols-2 gap-3">
@@ -731,27 +727,36 @@ export default function Charges() {
                             </p>
                           </div>
                         </div>
-                        <div className="mt-3 flex justify-end">
-                          {b.closed ? (
-                            <div className="flex items-center gap-2 flex-wrap justify-end">
-                              <span className="text-xs text-green-700 bg-green-50 border border-green-200 px-2 py-1 rounded-lg font-medium">✅ Bilan clôturé</span>
+                        <div className="mt-3 flex items-center justify-between gap-2 flex-wrap">
+                          <button
+                            onClick={() => downloadPdfLease(b)}
+                            disabled={pdfLoadingLeaseId === b.lease_id}
+                            className="flex items-center gap-2 text-sm px-3 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold transition-colors disabled:opacity-60"
+                          >
+                            {pdfLoadingLeaseId === b.lease_id ? '⏳ Génération…' : '📥 Décompte PDF'}
+                          </button>
+                          <div className="flex items-center gap-2 flex-wrap justify-end">
+                            {b.closed ? (
+                              <>
+                                <span className="text-xs text-green-700 bg-green-50 border border-green-200 px-2 py-1 rounded-lg font-medium">✅ Bilan clôturé</span>
+                                <button
+                                  onClick={() => cancelLeaseExercise(b)}
+                                  disabled={cancelingLeaseId === b.lease_id}
+                                  className="flex items-center gap-2 text-sm px-3 py-2 rounded-lg bg-red-500 hover:bg-red-600 text-white font-semibold transition-colors disabled:opacity-60"
+                                >
+                                  {cancelingLeaseId === b.lease_id ? '⏳ Annulation…' : '↩ Annuler la clôture'}
+                                </button>
+                              </>
+                            ) : (
                               <button
-                                onClick={() => cancelLeaseExercise(b)}
-                                disabled={cancelingLeaseId === b.lease_id}
-                                className="flex items-center gap-2 text-sm px-3 py-2 rounded-lg bg-red-500 hover:bg-red-600 text-white font-semibold transition-colors disabled:opacity-60"
+                                onClick={() => closeLeaseExercise(b)}
+                                disabled={closingLeaseId === b.lease_id}
+                                className="flex items-center gap-2 text-sm px-3 py-2 rounded-lg bg-orange-500 hover:bg-orange-600 text-white font-semibold transition-colors disabled:opacity-60"
                               >
-                                {cancelingLeaseId === b.lease_id ? '⏳ Annulation…' : '↩ Annuler la clôture'}
+                                {closingLeaseId === b.lease_id ? '⏳ Clôture du bilan…' : '✅ Clôturer le bilan annuel'}
                               </button>
-                            </div>
-                          ) : (
-                            <button
-                              onClick={() => closeLeaseExercise(b)}
-                              disabled={closingLeaseId === b.lease_id}
-                              className="flex items-center gap-2 text-sm px-3 py-2 rounded-lg bg-orange-500 hover:bg-orange-600 text-white font-semibold transition-colors disabled:opacity-60"
-                            >
-                              {closingLeaseId === b.lease_id ? '⏳ Clôture du bilan…' : '✅ Clôturer le bilan annuel'}
-                            </button>
-                          )}
+                            )}
+                          </div>
                         </div>
                       </div>
                     )