|
|
@@ -150,6 +150,131 @@ export default function Charges() {
|
|
|
finally { setBilanLoading(false) }
|
|
|
}
|
|
|
|
|
|
+ const printBilan = () => {
|
|
|
+ if (!bilan) return
|
|
|
+ const catLabel = v => CATEGORIES.find(c => c.value === v)?.label || v
|
|
|
+ const fmtEur = v => `${Number(v).toFixed(2)} €`
|
|
|
+ const solde = bilan.total_provisions_percues - bilan.total_charges_reelles
|
|
|
+ const isPos = solde >= 0
|
|
|
+
|
|
|
+ const tenantsRows = bilan.bilans.map(b => {
|
|
|
+ const pos = b.trop_percu >= 0
|
|
|
+ return `
|
|
|
+ <div class="tenant-block">
|
|
|
+ <div class="tenant-header">
|
|
|
+ <div>
|
|
|
+ <strong>${b.tenant_name}</strong>
|
|
|
+ ${b.tenant_email ? `<br><span class="small">${b.tenant_email}</span>` : ''}
|
|
|
+ </div>
|
|
|
+ <span class="badge ${pos ? 'badge-green' : 'badge-orange'}">${pos ? '↩ Remboursement' : '↑ Appel de fonds'}</span>
|
|
|
+ </div>
|
|
|
+ <table class="inner-table">
|
|
|
+ <tr>
|
|
|
+ <td>Provisions perçues</td>
|
|
|
+ <td class="amount">${fmtEur(b.provisions_percues)}</td>
|
|
|
+ </tr>
|
|
|
+ <tr>
|
|
|
+ <td>Quote-part des charges réelles (${b.ratio_percent}%)</td>
|
|
|
+ <td class="amount">${fmtEur(b.quote_part_charges)}</td>
|
|
|
+ </tr>
|
|
|
+ <tr class="${pos ? 'row-green' : 'row-orange'}">
|
|
|
+ <td><strong>${pos ? 'Trop-perçu à restituer' : 'Solde restant dû'}</strong></td>
|
|
|
+ <td class="amount"><strong>${fmtEur(Math.abs(b.trop_percu))}</strong></td>
|
|
|
+ </tr>
|
|
|
+ </table>
|
|
|
+ </div>`
|
|
|
+ }).join('')
|
|
|
+
|
|
|
+ const catRows = bilan.charges_by_category.map(c => {
|
|
|
+ const pct = bilan.total_charges_reelles > 0 ? (c.total / bilan.total_charges_reelles * 100).toFixed(1) : 0
|
|
|
+ return `<tr><td>${catLabel(c.category)}</td><td class="amount">${fmtEur(c.total)}</td><td class="pct">${pct}%</td></tr>`
|
|
|
+ }).join('')
|
|
|
+
|
|
|
+ const html = `<!DOCTYPE html>
|
|
|
+<html lang="fr">
|
|
|
+<head>
|
|
|
+<meta charset="UTF-8">
|
|
|
+<title>Bilan de charges ${bilan.year} — ${bilan.property?.name}</title>
|
|
|
+<style>
|
|
|
+ * { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
+ body { font-family: Arial, sans-serif; font-size: 13px; color: #1a1a1a; padding: 40px; max-width: 800px; margin: auto; }
|
|
|
+ h1 { font-size: 22px; font-weight: bold; margin-bottom: 4px; }
|
|
|
+ h2 { font-size: 15px; font-weight: 600; margin: 24px 0 10px; border-bottom: 1px solid #e5e7eb; padding-bottom: 6px; }
|
|
|
+ .subtitle { color: #6b7280; font-size: 12px; margin-bottom: 28px; }
|
|
|
+ .summary { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; margin-bottom: 24px; }
|
|
|
+ .card { border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px; text-align: center; }
|
|
|
+ .card .label { font-size: 11px; color: #6b7280; margin-bottom: 4px; }
|
|
|
+ .card .value { font-size: 20px; font-weight: bold; }
|
|
|
+ .card.red .value { color: #dc2626; }
|
|
|
+ .card.blue .value { color: #2563eb; }
|
|
|
+ .card.green { border-color: #bbf7d0; background: #f0fdf4; }
|
|
|
+ .card.green .value { color: #15803d; }
|
|
|
+ .card.orange { border-color: #fed7aa; background: #fff7ed; }
|
|
|
+ .card.orange .value { color: #c2410c; }
|
|
|
+ .card .note { font-size: 11px; color: #9ca3af; margin-top: 4px; }
|
|
|
+ table { width: 100%; border-collapse: collapse; }
|
|
|
+ table th { text-align: left; padding: 8px 10px; background: #f9fafb; font-size: 12px; color: #374151; }
|
|
|
+ table td { padding: 8px 10px; border-bottom: 1px solid #f3f4f6; }
|
|
|
+ td.amount { text-align: right; font-weight: 600; }
|
|
|
+ td.pct { text-align: right; color: #6b7280; font-size: 11px; }
|
|
|
+ .tenant-block { border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px; margin-bottom: 12px; }
|
|
|
+ .tenant-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; }
|
|
|
+ .tenant-header strong { font-size: 14px; }
|
|
|
+ .small { font-size: 11px; color: #9ca3af; }
|
|
|
+ .badge { font-size: 11px; font-weight: 600; padding: 3px 8px; border-radius: 99px; }
|
|
|
+ .badge-green { background: #dcfce7; color: #15803d; }
|
|
|
+ .badge-orange { background: #ffedd5; color: #c2410c; }
|
|
|
+ .inner-table td { padding: 6px 8px; border-bottom: 1px solid #f3f4f6; font-size: 12px; }
|
|
|
+ .row-green td { background: #f0fdf4; color: #15803d; }
|
|
|
+ .row-orange td { background: #fff7ed; color: #c2410c; }
|
|
|
+ .footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid #e5e7eb; font-size: 11px; color: #9ca3af; text-align: center; }
|
|
|
+ @media print {
|
|
|
+ body { padding: 20px; }
|
|
|
+ .no-print { display: none; }
|
|
|
+ }
|
|
|
+</style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+ <h1>Bilan annuel de charges ${bilan.year}</h1>
|
|
|
+ <p class="subtitle">${bilan.property?.name} — ${bilan.property?.address || ''}</p>
|
|
|
+
|
|
|
+ <div class="summary">
|
|
|
+ <div class="card">
|
|
|
+ <div class="label">Charges réelles</div>
|
|
|
+ <div class="value red">${fmtEur(bilan.total_charges_reelles)}</div>
|
|
|
+ <div class="note">${bilan.charges_count} facture${bilan.charges_count > 1 ? 's' : ''}</div>
|
|
|
+ </div>
|
|
|
+ <div class="card">
|
|
|
+ <div class="label">Provisions perçues</div>
|
|
|
+ <div class="value blue">${fmtEur(bilan.total_provisions_percues)}</div>
|
|
|
+ </div>
|
|
|
+ <div class="card ${isPos ? 'green' : 'orange'}">
|
|
|
+ <div class="label">${isPos ? 'Trop-perçu global' : 'Solde insuffisant'}</div>
|
|
|
+ <div class="value">${isPos ? '+' : '-'}${fmtEur(Math.abs(solde))}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ ${bilan.charges_by_category.length > 0 ? `
|
|
|
+ <h2>Répartition des charges par catégorie</h2>
|
|
|
+ <table>
|
|
|
+ <thead><tr><th>Catégorie</th><th style="text-align:right">Montant</th><th style="text-align:right">%</th></tr></thead>
|
|
|
+ <tbody>${catRows}</tbody>
|
|
|
+ <tfoot><tr><td><strong>Total</strong></td><td class="amount"><strong>${fmtEur(bilan.total_charges_reelles)}</strong></td><td></td></tr></tfoot>
|
|
|
+ </table>` : ''}
|
|
|
+
|
|
|
+ ${bilan.bilans.length > 0 ? `<h2>Décompte par locataire</h2>${tenantsRows}` : ''}
|
|
|
+
|
|
|
+ <div class="footer">Document généré le ${new Date().toLocaleDateString('fr-FR')} — Bilan de régularisation de charges ${bilan.year}</div>
|
|
|
+
|
|
|
+ <script>window.onload = () => window.print()<\/script>
|
|
|
+</body>
|
|
|
+</html>`
|
|
|
+
|
|
|
+ const w = window.open('', '_blank')
|
|
|
+ w.document.write(html)
|
|
|
+ w.document.close()
|
|
|
+ }
|
|
|
+
|
|
|
const fmt = v => `${Number(v).toFixed(2)} €`
|
|
|
const fmtDate = d => d ? new Date(d + 'T00:00:00').toLocaleDateString('fr-FR') : '—'
|
|
|
const totalAmount = charges.reduce((s, c) => s + c.amount, 0)
|
|
|
@@ -327,6 +452,12 @@ export default function Charges() {
|
|
|
{showBilan && bilan && (
|
|
|
<Modal title={`📊 Bilan annuel ${bilan.year} — ${bilan.property?.name}`} onClose={() => setShowBilan(false)}>
|
|
|
<div className="space-y-6">
|
|
|
+ <div className="flex justify-end">
|
|
|
+ <button onClick={printBilan}
|
|
|
+ className="btn-primary flex items-center gap-2 text-sm">
|
|
|
+ 🖨️ Imprimer / Exporter PDF
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
{/* Summary cards */}
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
<div className="bg-red-50 border border-red-100 rounded-xl p-4 text-center">
|