| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842 |
- import { useEffect, useState } from 'react'
- import api from '../api'
- import toast from 'react-hot-toast'
- import { useAuth } from '../context/AuthContext'
- const CURRENT_YEAR = new Date().getFullYear()
- function Modal({ title, onClose, children }) {
- return (
- <div className="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4">
- <div className="bg-white rounded-t-2xl sm:rounded-2xl shadow-xl w-full sm:max-w-lg max-h-[92vh] flex flex-col">
- <div className="flex items-center justify-between p-4 sm:p-6 border-b shrink-0 sticky top-0 bg-white z-10">
- <h2 className="text-lg font-semibold">{title}</h2>
- <button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none">×</button>
- </div>
- <div className="p-4 sm:p-6 overflow-y-auto">{children}</div>
- </div>
- </div>
- )
- }
- function CategoryBadge({ value, categories }) {
- const cat = categories.find(c => c.value === value)
- return <span className="inline-flex items-center gap-1 text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full font-medium">{cat ? `${cat.icon} ${cat.label}` : value}</span>
- }
- export default function Charges() {
- const { user } = useAuth()
- const [properties, setProperties] = useState([])
- const [charges, setCharges] = useState([])
- const [filterProperty, setFilterProperty] = useState('')
- const [filterYear, setFilterYear] = useState(String(CURRENT_YEAR))
- const [showModal, setShowModal] = useState(false)
- const [showBilan, setShowBilan] = useState(false)
- const [bilan, setBilan] = useState(null)
- const [bilanLoading, setBilanLoading] = useState(false)
- const [pdfLoadingLeaseId, setPdfLoadingLeaseId] = useState(null)
- const [closingLeaseId, setClosingLeaseId] = useState(null)
- const [cancelingLeaseId, setCancelingLeaseId] = useState(null)
- const [loading, setLoading] = useState(false)
- const [editCharge, setEditCharge] = useState(null)
- const [deductibleSummary, setDeductibleSummary] = useState(null)
- const [showDeductible, setShowDeductible] = useState(false)
- const [categories, setCategories] = useState([])
- const [availableYears, setAvailableYears] = useState([CURRENT_YEAR])
- const emptyForm = {
- property_id: '',
- category: 'eau',
- label: '',
- amount: '',
- date: new Date().toISOString().slice(0, 10),
- year: String(CURRENT_YEAR),
- notes: '',
- invoice: null,
- recoverable_type: 'recoverable',
- }
- const [form, setForm] = useState(emptyForm)
- const loadProperties = async () => {
- const { data } = await api.get('/properties')
- setProperties(data)
- if (data.length && !filterProperty) setFilterProperty(String(data[0].id))
- }
- const loadCharges = async () => {
- const params = new URLSearchParams()
- if (filterProperty) params.append('property_id', filterProperty)
- if (filterYear) params.append('year', filterYear)
- const { data } = await api.get(`/charges?${params}`)
- setCharges(data)
- }
- const loadDeductibleSummary = async () => {
- if (!filterProperty) { setDeductibleSummary(null); return }
- try {
- const { data } = await api.get(`/charges/deductible-summary/${filterProperty}`)
- setDeductibleSummary(data)
- } catch { setDeductibleSummary(null) }
- }
- // Load all distinct years from charges (unfiltered by year) to populate year selectors
- const loadAvailableYears = async () => {
- const params = new URLSearchParams()
- if (filterProperty) params.append('property_id', filterProperty)
- try {
- const { data } = await api.get(`/charges?${params}`)
- const yearsFromData = [...new Set(data.map(c => c.year).filter(Boolean))]
- const merged = [...new Set([CURRENT_YEAR, ...yearsFromData])].sort((a, b) => b - a)
- setAvailableYears(merged)
- } catch {}
- }
- useEffect(() => { loadProperties(); api.get('/categories').then(r => setCategories(r.data)) }, [])
- useEffect(() => { if (filterProperty) { loadCharges(); loadDeductibleSummary(); loadAvailableYears() } }, [filterProperty, filterYear])
- const openAdd = () => {
- setEditCharge(null)
- setForm({ ...emptyForm, property_id: filterProperty, year: filterYear })
- setShowModal(true)
- }
- const openEdit = (c) => {
- setEditCharge(c)
- setForm({
- property_id: String(c.property_id),
- category: c.category,
- label: c.label,
- amount: String(c.amount),
- date: c.date,
- year: String(c.year),
- notes: c.notes || '',
- invoice: null,
- recoverable_type: c.recoverable_type || (c.recoverable !== 0 ? 'recoverable' : 'none'),
- })
- setShowModal(true)
- }
- const handleSubmit = async e => {
- e.preventDefault(); setLoading(true)
- try {
- const fd = new FormData()
- Object.entries(form).forEach(([k, v]) => {
- if (k === 'invoice') { if (v) fd.append('invoice', v) }
- else fd.append(k, v)
- })
- if (editCharge) {
- await api.put(`/charges/${editCharge.id}`, fd, { headers: { 'Content-Type': 'multipart/form-data' } })
- toast.success('Charge mise à jour !')
- } else {
- await api.post('/charges', fd, { headers: { 'Content-Type': 'multipart/form-data' } })
- toast.success('Charge ajoutée !')
- }
- setShowModal(false)
- loadCharges()
- } catch (err) { toast.error(err.response?.data?.error || 'Erreur') }
- finally { setLoading(false) }
- }
- const handleDelete = async (id) => {
- if (!confirm('Supprimer cette charge ?')) return
- try { await api.delete(`/charges/${id}`); toast.success('Charge supprimée'); loadCharges() }
- catch { toast.error('Erreur lors de la suppression') }
- }
- const downloadInvoice = async (charge) => {
- try {
- const token = localStorage.getItem('token')
- const resp = await fetch(`/api/charges/${charge.id}/invoice`, {
- headers: { Authorization: `Bearer ${token}` }
- })
- if (!resp.ok) throw new Error()
- const blob = await resp.blob()
- const url = window.URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url; a.download = charge.invoice_name; a.click()
- window.URL.revokeObjectURL(url)
- } catch { toast.error('Impossible de télécharger la facture') }
- }
- const loadBilan = async () => {
- if (!filterProperty || !filterYear) return
- setBilanLoading(true)
- try {
- const { data } = await api.get(`/charges/bilan/${filterProperty}/${filterYear}`)
- setBilan(data)
- setShowBilan(true)
- } catch (err) { toast.error(err.response?.data?.error || 'Erreur bilan') }
- finally { setBilanLoading(false) }
- }
- const printBilan = () => {
- if (!bilan) return
- const catLabel = v => { const c = categories.find(c => c.value === v); return c ? `${c.icon} ${c.label}` : v }
- const fmtEur = v => `${Number(v).toFixed(2)} €`
- const solde = bilan.total_provisions_percues - bilan.total_charges_reelles
- const isPos = solde >= 0
- const ownerName = (user?.first_name && user?.last_name) ? `${user.first_name} ${user.last_name}` : (user?.name || '')
- const ownerBlock = `<div class="owner-block">
- <span class="owner-label">BAILLEUR</span>
- <strong>${ownerName}</strong>
- ${user?.address ? `<br>${user.address}` : ''}
- ${user?.email ? `<br>${user.email}` : ''}
- ${user?.phone ? `<br>${user.phone}` : ''}
- </div>`
- const tenantsRows = bilan.bilans.map(b => {
- const pos = b.trop_percu >= 0
- 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>Occupation</td>
- <td class="amount">${b.occupied_days} / ${b.year_days} jours (${Number(b.ratio_percent || 0).toFixed(2)}%)</td>
- </tr>
- <tr>
- <td>Provisions perçues</td>
- <td class="amount">${fmtEur(b.provisions_percues)}</td>
- </tr>
- <tr>
- <td>Quote-part des charges réelles (prorata occupation)</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; }
- .owner-block { border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px 14px; margin-bottom: 20px; font-size: 12px; line-height: 1.6; }
- .owner-label { display: block; font-size: 10px; color: #6b7280; font-weight: 600; margin-bottom: 2px; letter-spacing: 0.05em; }
- @media print {
- body { padding: 20px; }
- .no-print { display: none; }
- }
- </style>
- </head>
- <body>
- <h1>Bilan annuel de charges ${bilan.year}</h1>
- <p class="subtitle">${bilan.property?.name} — ${bilan.property?.address || ''}</p>
- ${ownerBlock}
- <div class="summary">
- <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 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?lease_id=${leaseBilan.lease_id}`, {
- headers: { Authorization: `Bearer ${token}` }
- })
- if (!resp.ok) throw new Error()
- const blob = await resp.blob()
- const url = window.URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- const 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 { setPdfLoadingLeaseId(null) }
- }
- const cancelLeaseExercise = async (leaseBilan) => {
- if (!filterProperty || !filterYear || !leaseBilan?.lease_id) return
- const appliedWarning = leaseBilan.applied_payment_id
- ? '\n\n⚠️ Cette régularisation a déjà été appliquée à un paiement. Ce paiement sera corrigé (montant de régularisation remis à 0).'
- : ''
- if (!confirm(
- `Annuler la clôture du bilan annuel des charges ${filterYear} pour ${leaseBilan.tenant_name} ?` +
- appliedWarning +
- '\n\nCette action est irréversible.'
- )) return
- setCancelingLeaseId(leaseBilan.lease_id)
- try {
- await api.delete(`/charges/bilan/${filterProperty}/${filterYear}/close/${leaseBilan.lease_id}`)
- toast.success(`Clôture annulée pour ${leaseBilan.tenant_name} (${filterYear})`)
- await loadBilan()
- } catch (err) {
- toast.error(err.response?.data?.error || 'Erreur lors de l\'annulation')
- } finally {
- setCancelingLeaseId(null)
- }
- }
- const closeLeaseExercise = async (leaseBilan) => {
- if (!filterProperty || !filterYear || !leaseBilan?.lease_id) return
- if (!confirm(
- `Clôturer le bilan annuel des charges ${filterYear} pour ${leaseBilan.tenant_name} ?\n\n` +
- `Le calcul se fera au prorata d'occupation (${leaseBilan.occupied_days}/${leaseBilan.year_days} jours).\n\n` +
- `Cette opération est définitive.`
- )) return
- setClosingLeaseId(leaseBilan.lease_id)
- try {
- const { data } = await api.post(`/charges/bilan/${filterProperty}/${filterYear}/close`, {
- lease_id: leaseBilan.lease_id
- })
- const reg = data.regularizations?.[0]
- if (reg) {
- toast.success(
- `Bilan annuel clôturé (${filterYear}) : ${reg.tenant} | Prorata ${reg.occupied_days}/${reg.year_days} jours | ` +
- `${reg.trop_percu >= 0 ? '-' : '+'}${Math.abs(reg.trop_percu).toFixed(2)} € sur le prochain loyer`,
- { duration: 6000 }
- )
- } else {
- toast.success(`Bilan annuel clôturé pour ${filterYear}`)
- }
- await loadBilan()
- } catch (err) {
- toast.error(err.response?.data?.error || 'Erreur lors de la clôture')
- } finally {
- setClosingLeaseId(null)
- }
- }
- 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)
- return (
- <div>
- <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
- <div>
- <h1 className="text-2xl font-bold text-gray-900">🧾 Gestion des charges</h1>
- <p className="text-gray-500 text-sm mt-1">Charges réelles par immeuble et bilan annuel</p>
- </div>
- <div className="flex flex-wrap gap-2">
- <button onClick={loadBilan} disabled={!filterProperty || bilanLoading}
- className="btn-secondary flex items-center gap-2 text-sm">
- {bilanLoading ? '...' : '📊 Bilan annuel'}
- </button>
- <button onClick={openAdd} className="btn-primary text-sm">+ Ajouter une charge</button>
- </div>
- </div>
- {/* Filters */}
- <div className="card mb-6 flex flex-wrap gap-4">
- <div className="flex-1 min-w-48">
- <label className="label">Immeuble / Bien</label>
- <select className="input" value={filterProperty} onChange={e => setFilterProperty(e.target.value)}>
- <option value="">Tous les biens</option>
- {properties.map(p => <option key={p.id} value={p.id}>{p.name} — {p.address}</option>)}
- </select>
- </div>
- <div className="w-36">
- <label className="label">Année</label>
- <select className="input" value={filterYear} onChange={e => setFilterYear(e.target.value)}>
- {availableYears.map(y => <option key={y} value={y}>{y}</option>)}
- </select>
- </div>
- {filterProperty && filterYear && (
- <div className="flex items-end">
- <div className="bg-blue-50 border border-blue-200 rounded-lg px-4 py-2 text-sm">
- <span className="text-blue-500">Total charges :</span>
- <span className="text-blue-800 font-bold ml-2">{fmt(totalAmount)}</span>
- <span className="text-blue-400 ml-2">({charges.length} ligne{charges.length > 1 ? 's' : ''})</span>
- </div>
- </div>
- )}
- </div>
- {/* Deductible summary table (10 years) */}
- {filterProperty && deductibleSummary && (
- <div className="card mb-6">
- <button
- onClick={() => setShowDeductible(v => !v)}
- className="w-full flex items-center justify-between text-left"
- >
- <div className="flex items-center gap-2">
- <span className="text-lg">💼</span>
- <div>
- <h3 className="text-sm font-semibold text-purple-800">Charges déductibles des impôts — 10 ans</h3>
- <p className="text-xs text-purple-500">
- Total cumulé : <span className="font-bold">{fmt(deductibleSummary.grand_total)}</span>
- </p>
- </div>
- </div>
- <span className={`text-gray-400 transition-transform ${showDeductible ? 'rotate-180' : ''}`}>▼</span>
- </button>
- {showDeductible && (
- <div className="mt-4 overflow-x-auto">
- <table className="w-full text-sm">
- <thead>
- <tr className="bg-purple-50 border-b border-purple-100">
- <th className="text-left px-4 py-2 text-purple-700 font-semibold">Année</th>
- <th className="text-right px-4 py-2 text-purple-700 font-semibold">Montant déductible</th>
- <th className="text-right px-4 py-2 text-purple-700 font-semibold">Nb factures</th>
- </tr>
- </thead>
- <tbody className="divide-y divide-purple-50">
- {deductibleSummary.years.map(y => (
- <tr key={y.year} className={`hover:bg-purple-50/50 ${String(y.year) === filterYear ? 'bg-purple-50 font-semibold' : ''}`}>
- <td className="px-4 py-2 text-gray-700">{y.year}</td>
- <td className={`px-4 py-2 text-right ${y.total > 0 ? 'text-purple-700' : 'text-gray-300'}`}>
- {y.total > 0 ? fmt(y.total) : '—'}
- </td>
- <td className={`px-4 py-2 text-right ${y.count > 0 ? 'text-gray-600' : 'text-gray-300'}`}>
- {y.count > 0 ? y.count : '—'}
- </td>
- </tr>
- ))}
- </tbody>
- <tfoot className="bg-purple-50 border-t-2 border-purple-200">
- <tr>
- <td className="px-4 py-2 font-bold text-purple-800">Total (10 ans)</td>
- <td className="px-4 py-2 text-right font-bold text-purple-800">{fmt(deductibleSummary.grand_total)}</td>
- <td className="px-4 py-2 text-right font-bold text-gray-600">
- {deductibleSummary.years.reduce((s, y) => s + y.count, 0)}
- </td>
- </tr>
- </tfoot>
- </table>
- </div>
- )}
- </div>
- )}
- {/* Charges list */}
- {charges.length === 0 ? (
- <div className="card text-center py-12">
- <p className="text-4xl mb-3">🧾</p>
- <p className="text-gray-500">Aucune charge enregistrée pour cette période.</p>
- <button onClick={openAdd} className="btn-primary mt-4">Ajouter une première charge</button>
- </div>
- ) : (
- <div>
- {/* Desktop table */}
- <div className="card overflow-hidden p-0 hidden md:block">
- <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">Date</th>
- <th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Catégorie</th>
- <th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Libellé</th>
- <th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Immeuble</th>
- <th className="text-right px-6 py-3 text-sm font-semibold text-gray-600">Montant</th>
- <th className="text-center px-6 py-3 text-sm font-semibold text-gray-600">Récup.</th>
- <th className="text-center px-6 py-3 text-sm font-semibold text-gray-600">Facture</th>
- <th className="px-6 py-3"></th>
- </tr>
- </thead>
- <tbody className="divide-y divide-gray-50">
- {charges.map(c => (
- <tr key={c.id} className="hover:bg-gray-50">
- <td className="px-6 py-4 text-sm text-gray-600">{fmtDate(c.date)}</td>
- <td className="px-6 py-4"><CategoryBadge value={c.category} categories={categories} /></td>
- <td className="px-6 py-4 text-gray-900 font-medium">{c.label}</td>
- <td className="px-6 py-4 text-sm text-gray-500">{c.property_name}</td>
- <td className="px-6 py-4 text-right font-semibold text-gray-900">{fmt(c.amount)}</td>
- <td className="px-6 py-4 text-center">
- {c.recoverable_type === 'recoverable'
- ? <span className="text-xs bg-green-100 text-green-700 font-semibold px-2 py-0.5 rounded-full">✓ Locataire</span>
- : c.recoverable_type === 'deductible'
- ? <span className="text-xs bg-purple-100 text-purple-700 font-semibold px-2 py-0.5 rounded-full">💼 Déductible</span>
- : <span className="text-xs bg-gray-100 text-gray-500 font-semibold px-2 py-0.5 rounded-full">✗ Aucun</span>}
- </td>
- <td className="px-6 py-4 text-center">
- {c.invoice_path ? (
- <button onClick={() => downloadInvoice(c)} className="text-green-600 hover:text-green-800 text-sm font-medium" title={c.invoice_name}>
- 📎 {c.invoice_name?.length > 20 ? c.invoice_name.slice(0, 17) + '…' : c.invoice_name}
- </button>
- ) : <span className="text-gray-300 text-xs">—</span>}
- </td>
- <td className="px-6 py-4 text-right space-x-2 whitespace-nowrap">
- <button onClick={() => openEdit(c)} className="text-blue-500 hover:text-blue-700 text-sm">✏️</button>
- <button onClick={() => handleDelete(c.id)} className="text-gray-400 hover:text-red-600 ml-1">🗑️</button>
- </td>
- </tr>
- ))}
- </tbody>
- <tfoot className="bg-gray-50 border-t-2 border-gray-200">
- <tr>
- <td colSpan={5} className="px-6 py-3 text-sm font-semibold text-gray-700">Total</td>
- <td className="px-6 py-3 text-right font-bold text-gray-900">{fmt(totalAmount)}</td>
- <td colSpan={2}></td>
- </tr>
- </tfoot>
- </table>
- </div>
- {/* Mobile cards */}
- <div className="md:hidden space-y-3">
- {charges.map(c => (
- <div key={c.id} className="card">
- <div className="flex items-start justify-between mb-1">
- <div className="flex-1 min-w-0">
- <p className="font-semibold text-gray-900 truncate">{c.label}</p>
- <div className="flex items-center gap-2 mt-1 flex-wrap">
- <CategoryBadge value={c.category} categories={categories} />
- {c.recoverable_type === 'recoverable'
- ? <span className="text-xs bg-green-100 text-green-700 px-1.5 py-0.5 rounded-full font-medium">Locataire</span>
- : c.recoverable_type === 'deductible'
- ? <span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded-full font-medium">Déductible</span>
- : <span className="text-xs bg-gray-100 text-gray-400 px-1.5 py-0.5 rounded-full font-medium">Aucun</span>}
- </div>
- </div>
- <p className="font-bold text-gray-900 text-lg ml-3 shrink-0">{fmt(c.amount)}</p>
- </div>
- <p className="text-sm text-gray-500">{fmtDate(c.date)}</p>
- <div className="flex items-center justify-between mt-3 pt-3 border-t border-gray-50">
- {c.invoice_path
- ? <button onClick={() => downloadInvoice(c)} className="text-green-600 text-sm">📎 Facture</button>
- : <span />}
- <div className="flex gap-3">
- <button onClick={() => openEdit(c)} className="text-blue-500 text-sm">✏️ Modifier</button>
- <button onClick={() => handleDelete(c.id)} className="text-red-400 text-sm">🗑️</button>
- </div>
- </div>
- </div>
- ))}
- <div className="card bg-gray-50 flex justify-between items-center">
- <span className="font-semibold text-gray-700">Total</span>
- <span className="font-bold text-gray-900">{fmt(totalAmount)}</span>
- </div>
- </div>
- </div>
- )}
- {/* Add/Edit Modal */}
- {showModal && (
- <Modal title={editCharge ? 'Modifier la charge' : 'Ajouter une charge'} onClose={() => setShowModal(false)}>
- <form onSubmit={handleSubmit} className="space-y-4">
- <div>
- <label className="label">Immeuble / Bien *</label>
- <select className="input" required value={form.property_id}
- onChange={e => setForm(f => ({ ...f, property_id: e.target.value }))}>
- <option value="">— Sélectionner —</option>
- {properties.map(p => <option key={p.id} value={p.id}>{p.name} — {p.address}</option>)}
- </select>
- </div>
- <div className="grid grid-cols-2 gap-4">
- <div>
- <label className="label">Catégorie *</label>
- <select className="input" value={form.category}
- onChange={e => setForm(f => ({ ...f, category: e.target.value }))}>
- {categories.map(c => <option key={c.value} value={c.value}>{c.icon} {c.label}</option>)}
- </select>
- </div>
- <div>
- <label className="label">Année *</label>
- <select className="input" value={form.year}
- onChange={e => setForm(f => ({ ...f, year: e.target.value }))}>
- {availableYears.map(y => <option key={y} value={y}>{y}</option>)}
- </select>
- </div>
- </div>
- <div>
- <label className="label">Libellé *</label>
- <input className="input" required value={form.label}
- onChange={e => setForm(f => ({ ...f, label: e.target.value }))}
- placeholder="Ex : Facture eau T1 2024" />
- </div>
- <div className="grid grid-cols-2 gap-4">
- <div>
- <label className="label">Montant (€) *</label>
- <input className="input" type="number" step="0.01" min="0" required value={form.amount}
- onChange={e => setForm(f => ({ ...f, amount: e.target.value }))} />
- </div>
- <div>
- <label className="label">Date de la dépense *</label>
- <input className="input" type="date" required value={form.date}
- onChange={e => setForm(f => ({ ...f, date: e.target.value }))} />
- </div>
- </div>
- <div>
- <label className="label">Notes (optionnel)</label>
- <input className="input" value={form.notes}
- onChange={e => setForm(f => ({ ...f, notes: e.target.value }))}
- placeholder="Numéro de facture, prestataire…" />
- </div>
- <div>
- <label className="label">Affectation de la charge</label>
- <div className="space-y-2 mt-1">
- {[
- { value: 'recoverable', label: '✓ Récupérable auprès du locataire', sub: 'Incluse dans le bilan de régularisation annuel', color: 'green' },
- { value: 'deductible', label: '💼 Déductible des impôts', sub: 'Charge du propriétaire déductible fiscalement', color: 'purple' },
- { value: 'none', label: '✗ Aucun des deux', sub: 'Charge non récupérable et non déductible', color: 'gray' },
- ].map(opt => (
- <label key={opt.value} className={`flex items-start gap-3 cursor-pointer rounded-xl border-2 p-3 transition-colors ${form.recoverable_type === opt.value ? `border-${opt.color}-400 bg-${opt.color}-50` : 'border-gray-100 hover:border-gray-200'}`}>
- <input type="radio" name="recoverable_type" value={opt.value}
- checked={form.recoverable_type === opt.value}
- onChange={e => setForm(f => ({ ...f, recoverable_type: e.target.value }))}
- className="mt-0.5 accent-blue-600" />
- <div>
- <p className="text-sm font-medium text-gray-800">{opt.label}</p>
- <p className="text-xs text-gray-400">{opt.sub}</p>
- </div>
- </label>
- ))}
- </div>
- </div>
- <div>
- <label className="label">Facture (PDF, JPG, PNG — max 10 Mo)</label>
- {editCharge?.invoice_name && !form.invoice && (
- <p className="text-xs text-green-600 mb-1">📎 Fichier actuel : {editCharge.invoice_name}</p>
- )}
- <input className="input" type="file" accept=".pdf,.jpg,.jpeg,.png,.webp"
- onChange={e => setForm(f => ({ ...f, invoice: e.target.files[0] || null }))} />
- </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 ? '...' : editCharge ? 'Mettre à jour' : 'Ajouter'}
- </button>
- </div>
- </form>
- </Modal>
- )}
- {/* Bilan annuel Modal */}
- {showBilan && bilan && (
- <Modal title={`📊 Bilan annuel ${bilan.year} — ${bilan.property?.name}`} onClose={() => setShowBilan(false)}>
- <div className="space-y-6">
- <div className="flex justify-end gap-2 flex-wrap">
- <button onClick={printBilan}
- className="btn-secondary flex items-center gap-2 text-sm">
- 🖨️ Aperçu / Imprimer
- </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">
- <p className="text-xs text-red-500 font-medium mb-1">Charges récupérables</p>
- <p className="text-2xl font-bold text-red-700">{fmt(bilan.total_charges_reelles)}</p>
- <p className="text-xs text-red-400 mt-1">{bilan.charges_count} facture{bilan.charges_count > 1 ? 's' : ''}</p>
- </div>
- <div className="bg-blue-50 border border-blue-100 rounded-xl p-4 text-center">
- <p className="text-xs text-blue-500 font-medium mb-1">Provisions perçues</p>
- <p className="text-2xl font-bold text-blue-700">{fmt(bilan.total_provisions_percues)}</p>
- <p className="text-xs text-blue-400 mt-1">de tous les locataires</p>
- </div>
- </div>
- {/* Charges déductibles (info) */}
- {bilan.total_deductible > 0 && (
- <div className="bg-purple-50 border border-purple-200 rounded-xl p-3 flex items-center justify-between text-sm">
- <span className="text-purple-700">💼 Charges déductibles des impôts (à votre charge)</span>
- <span className="font-semibold text-purple-800">{fmt(bilan.total_deductible)}</span>
- </div>
- )}
- {/* Charges non récupérables (info) */}
- {bilan.total_non_recoverable > 0 && (
- <div className="bg-gray-50 border border-gray-200 rounded-xl p-3 flex items-center justify-between text-sm">
- <span className="text-gray-500">✗ Sans affectation (à votre charge, non déductible)</span>
- <span className="font-semibold text-gray-700">{fmt(bilan.total_non_recoverable)}</span>
- </div>
- )}
- {/* Solde global */}
- {(() => {
- const solde = bilan.total_provisions_percues - bilan.total_charges_reelles
- const isPositive = solde >= 0
- return (
- <div className={`rounded-xl p-4 text-center border ${isPositive ? 'bg-green-50 border-green-200' : 'bg-orange-50 border-orange-200'}`}>
- <p className={`text-sm font-medium ${isPositive ? 'text-green-600' : 'text-orange-600'}`}>
- {isPositive ? '✅ Trop-perçu global (à restituer)' : '⚠️ Solde insuffisant (à appeler)'}
- </p>
- <p className={`text-3xl font-bold mt-1 ${isPositive ? 'text-green-700' : 'text-orange-700'}`}>
- {isPositive ? '+' : ''}{fmt(solde)}
- </p>
- </div>
- )
- })()}
- {/* Charges par catégorie */}
- {bilan.charges_by_category.length > 0 && (
- <div>
- <h3 className="text-sm font-semibold text-gray-700 mb-2">Répartition par catégorie</h3>
- <div className="space-y-1">
- {bilan.charges_by_category.map(c => {
- const cat = categories.find(x => x.value === c.category)
- const pct = bilan.total_charges_reelles > 0 ? (c.total / bilan.total_charges_reelles * 100).toFixed(1) : 0
- return (
- <div key={c.category} className="flex items-center justify-between text-sm py-1">
- <span className="text-gray-600">{cat ? `${cat.icon} ${cat.label}` : c.category}</span>
- <div className="flex items-center gap-3">
- <div className="w-24 bg-gray-100 rounded-full h-1.5">
- <div className="bg-blue-500 h-1.5 rounded-full" style={{ width: `${pct}%` }}></div>
- </div>
- <span className="text-gray-500 w-8 text-right text-xs">{pct}%</span>
- <span className="font-medium text-gray-900 w-20 text-right">{fmt(c.total)}</span>
- </div>
- </div>
- )
- })}
- </div>
- </div>
- )}
- {/* Par locataire */}
- {bilan.bilans.length > 0 ? (
- <div>
- <h3 className="text-sm font-semibold text-gray-700 mb-3">Décompte par locataire (prorata d'occupation)</h3>
- <div className="space-y-3">
- {bilan.bilans.map(b => {
- const isPos = b.trop_percu >= 0
- return (
- <div key={b.lease_id} className="border rounded-xl p-4">
- <div className="flex items-start justify-between mb-3">
- <div>
- <p className="font-semibold text-gray-900">{b.tenant_name}</p>
- {b.tenant_email && <p className="text-xs text-gray-400">{b.tenant_email}</p>}
- </div>
- <span className={`text-sm font-bold px-2 py-0.5 rounded-full ${isPos ? 'bg-green-100 text-green-700' : 'bg-orange-100 text-orange-700'}`}>
- {isPos ? '↩ Remboursement' : '↑ Appel de fonds'}
- </span>
- </div>
- <div className="mb-3 text-xs text-gray-500 bg-gray-50 rounded-lg px-3 py-2">
- Occupation sur l'année: <span className="font-semibold text-gray-700">{b.occupied_days} / {b.year_days} jours ({Number(b.ratio_percent || 0).toFixed(2)}%)</span>
- </div>
- <div className="grid grid-cols-3 gap-2 text-sm">
- <div className="bg-gray-50 rounded-lg p-2 text-center">
- <p className="text-xs text-gray-500">Provisions perçues</p>
- <p className="font-semibold text-gray-800">{fmt(b.provisions_percues)}</p>
- </div>
- <div className="bg-gray-50 rounded-lg p-2 text-center">
- <p className="text-xs text-gray-500">Quote-part réelle (prorata)</p>
- <p className="font-semibold text-gray-800">{fmt(b.quote_part_charges)}</p>
- </div>
- <div className={`rounded-lg p-2 text-center ${isPos ? 'bg-green-50' : 'bg-orange-50'}`}>
- <p className={`text-xs font-medium ${isPos ? 'text-green-600' : 'text-orange-600'}`}>
- {isPos ? 'Trop-perçu' : 'Solde dû'}
- </p>
- <p className={`font-bold text-lg ${isPos ? 'text-green-700' : 'text-orange-700'}`}>
- {fmt(Math.abs(b.trop_percu))}
- </p>
- </div>
- </div>
- <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={() => 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>
- )
- })}
- </div>
- </div>
- ) : (
- <p className="text-center text-gray-400 text-sm py-4">Aucun locataire trouvé pour cette période.</p>
- )}
- </div>
- </Modal>
- )}
- </div>
- )
- }
|