Charges.jsx 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840
  1. import { useEffect, useState } from 'react'
  2. import api from '../api'
  3. import toast from 'react-hot-toast'
  4. import { useAuth } from '../context/AuthContext'
  5. const CATEGORIES = [
  6. { value: 'eau', label: '💧 Eau' },
  7. { value: 'gaz', label: '🔥 Gaz' },
  8. { value: 'electricite', label: '⚡ Électricité' },
  9. { value: 'entretien', label: '🔧 Entretien / Réparations' },
  10. { value: 'ascenseur', label: '🛗 Ascenseur' },
  11. { value: 'ordures', label: '🗑️ Ordures ménagères' },
  12. { value: 'assurance', label: '🛡️ Assurance' },
  13. { value: 'autre', label: '📦 Autre' },
  14. ]
  15. const CURRENT_YEAR = new Date().getFullYear()
  16. const YEARS = Array.from({ length: 6 }, (_, i) => CURRENT_YEAR - i)
  17. function Modal({ title, onClose, children }) {
  18. return (
  19. <div className="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4">
  20. <div className="bg-white rounded-t-2xl sm:rounded-2xl shadow-xl w-full sm:max-w-lg max-h-[92vh] flex flex-col">
  21. <div className="flex items-center justify-between p-4 sm:p-6 border-b shrink-0 sticky top-0 bg-white z-10">
  22. <h2 className="text-lg font-semibold">{title}</h2>
  23. <button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none">&times;</button>
  24. </div>
  25. <div className="p-4 sm:p-6 overflow-y-auto">{children}</div>
  26. </div>
  27. </div>
  28. )
  29. }
  30. function CategoryBadge({ value }) {
  31. const cat = CATEGORIES.find(c => c.value === value)
  32. 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?.label || value}</span>
  33. }
  34. export default function Charges() {
  35. const { user } = useAuth()
  36. const [properties, setProperties] = useState([])
  37. const [charges, setCharges] = useState([])
  38. const [filterProperty, setFilterProperty] = useState('')
  39. const [filterYear, setFilterYear] = useState(String(CURRENT_YEAR))
  40. const [showModal, setShowModal] = useState(false)
  41. const [showBilan, setShowBilan] = useState(false)
  42. const [bilan, setBilan] = useState(null)
  43. const [bilanLoading, setBilanLoading] = useState(false)
  44. const [pdfLoadingLeaseId, setPdfLoadingLeaseId] = useState(null)
  45. const [closingLeaseId, setClosingLeaseId] = useState(null)
  46. const [cancelingLeaseId, setCancelingLeaseId] = useState(null)
  47. const [loading, setLoading] = useState(false)
  48. const [editCharge, setEditCharge] = useState(null)
  49. const [deductibleSummary, setDeductibleSummary] = useState(null)
  50. const [showDeductible, setShowDeductible] = useState(false)
  51. const emptyForm = {
  52. property_id: '',
  53. category: 'eau',
  54. label: '',
  55. amount: '',
  56. date: new Date().toISOString().slice(0, 10),
  57. year: String(CURRENT_YEAR),
  58. notes: '',
  59. invoice: null,
  60. recoverable_type: 'recoverable',
  61. }
  62. const [form, setForm] = useState(emptyForm)
  63. const loadProperties = async () => {
  64. const { data } = await api.get('/properties')
  65. setProperties(data)
  66. if (data.length && !filterProperty) setFilterProperty(String(data[0].id))
  67. }
  68. const loadCharges = async () => {
  69. const params = new URLSearchParams()
  70. if (filterProperty) params.append('property_id', filterProperty)
  71. if (filterYear) params.append('year', filterYear)
  72. const { data } = await api.get(`/charges?${params}`)
  73. setCharges(data)
  74. }
  75. const loadDeductibleSummary = async () => {
  76. if (!filterProperty) { setDeductibleSummary(null); return }
  77. try {
  78. const { data } = await api.get(`/charges/deductible-summary/${filterProperty}`)
  79. setDeductibleSummary(data)
  80. } catch { setDeductibleSummary(null) }
  81. }
  82. useEffect(() => { loadProperties() }, [])
  83. useEffect(() => { if (filterProperty) { loadCharges(); loadDeductibleSummary() } }, [filterProperty, filterYear])
  84. const openAdd = () => {
  85. setEditCharge(null)
  86. setForm({ ...emptyForm, property_id: filterProperty, year: filterYear })
  87. setShowModal(true)
  88. }
  89. const openEdit = (c) => {
  90. setEditCharge(c)
  91. setForm({
  92. property_id: String(c.property_id),
  93. category: c.category,
  94. label: c.label,
  95. amount: String(c.amount),
  96. date: c.date,
  97. year: String(c.year),
  98. notes: c.notes || '',
  99. invoice: null,
  100. recoverable_type: c.recoverable_type || (c.recoverable !== 0 ? 'recoverable' : 'none'),
  101. })
  102. setShowModal(true)
  103. }
  104. const handleSubmit = async e => {
  105. e.preventDefault(); setLoading(true)
  106. try {
  107. const fd = new FormData()
  108. Object.entries(form).forEach(([k, v]) => {
  109. if (k === 'invoice') { if (v) fd.append('invoice', v) }
  110. else fd.append(k, v)
  111. })
  112. if (editCharge) {
  113. await api.put(`/charges/${editCharge.id}`, fd, { headers: { 'Content-Type': 'multipart/form-data' } })
  114. toast.success('Charge mise à jour !')
  115. } else {
  116. await api.post('/charges', fd, { headers: { 'Content-Type': 'multipart/form-data' } })
  117. toast.success('Charge ajoutée !')
  118. }
  119. setShowModal(false)
  120. loadCharges()
  121. } catch (err) { toast.error(err.response?.data?.error || 'Erreur') }
  122. finally { setLoading(false) }
  123. }
  124. const handleDelete = async (id) => {
  125. if (!confirm('Supprimer cette charge ?')) return
  126. try { await api.delete(`/charges/${id}`); toast.success('Charge supprimée'); loadCharges() }
  127. catch { toast.error('Erreur lors de la suppression') }
  128. }
  129. const downloadInvoice = async (charge) => {
  130. try {
  131. const token = localStorage.getItem('token')
  132. const resp = await fetch(`/api/charges/${charge.id}/invoice`, {
  133. headers: { Authorization: `Bearer ${token}` }
  134. })
  135. if (!resp.ok) throw new Error()
  136. const blob = await resp.blob()
  137. const url = window.URL.createObjectURL(blob)
  138. const a = document.createElement('a')
  139. a.href = url; a.download = charge.invoice_name; a.click()
  140. window.URL.revokeObjectURL(url)
  141. } catch { toast.error('Impossible de télécharger la facture') }
  142. }
  143. const loadBilan = async () => {
  144. if (!filterProperty || !filterYear) return
  145. setBilanLoading(true)
  146. try {
  147. const { data } = await api.get(`/charges/bilan/${filterProperty}/${filterYear}`)
  148. setBilan(data)
  149. setShowBilan(true)
  150. } catch (err) { toast.error(err.response?.data?.error || 'Erreur bilan') }
  151. finally { setBilanLoading(false) }
  152. }
  153. const printBilan = () => {
  154. if (!bilan) return
  155. const catLabel = v => CATEGORIES.find(c => c.value === v)?.label || v
  156. const fmtEur = v => `${Number(v).toFixed(2)} €`
  157. const solde = bilan.total_provisions_percues - bilan.total_charges_reelles
  158. const isPos = solde >= 0
  159. const ownerName = (user?.first_name && user?.last_name) ? `${user.first_name} ${user.last_name}` : (user?.name || '')
  160. const ownerBlock = `<div class="owner-block">
  161. <span class="owner-label">BAILLEUR</span>
  162. <strong>${ownerName}</strong>
  163. ${user?.address ? `<br>${user.address}` : ''}
  164. ${user?.email ? `<br>${user.email}` : ''}
  165. ${user?.phone ? `<br>${user.phone}` : ''}
  166. </div>`
  167. const tenantsRows = bilan.bilans.map(b => {
  168. const pos = b.trop_percu >= 0
  169. return `
  170. <div class="tenant-block">
  171. <div class="tenant-header">
  172. <div>
  173. <strong>${b.tenant_name}</strong>
  174. ${b.tenant_email ? `<br><span class="small">${b.tenant_email}</span>` : ''}
  175. </div>
  176. <span class="badge ${pos ? 'badge-green' : 'badge-orange'}">${pos ? '↩ Remboursement' : '↑ Appel de fonds'}</span>
  177. </div>
  178. <table class="inner-table">
  179. <tr>
  180. <td>Occupation</td>
  181. <td class="amount">${b.occupied_days} / ${b.year_days} jours (${Number(b.ratio_percent || 0).toFixed(2)}%)</td>
  182. </tr>
  183. <tr>
  184. <td>Provisions perçues</td>
  185. <td class="amount">${fmtEur(b.provisions_percues)}</td>
  186. </tr>
  187. <tr>
  188. <td>Quote-part des charges réelles (prorata occupation)</td>
  189. <td class="amount">${fmtEur(b.quote_part_charges)}</td>
  190. </tr>
  191. <tr class="${pos ? 'row-green' : 'row-orange'}">
  192. <td><strong>${pos ? 'Trop-perçu à restituer' : 'Solde restant dû'}</strong></td>
  193. <td class="amount"><strong>${fmtEur(Math.abs(b.trop_percu))}</strong></td>
  194. </tr>
  195. </table>
  196. </div>`
  197. }).join('')
  198. const catRows = bilan.charges_by_category.map(c => {
  199. const pct = bilan.total_charges_reelles > 0 ? (c.total / bilan.total_charges_reelles * 100).toFixed(1) : 0
  200. return `<tr><td>${catLabel(c.category)}</td><td class="amount">${fmtEur(c.total)}</td><td class="pct">${pct}%</td></tr>`
  201. }).join('')
  202. const html = `<!DOCTYPE html>
  203. <html lang="fr">
  204. <head>
  205. <meta charset="UTF-8">
  206. <title>Bilan de charges ${bilan.year} — ${bilan.property?.name}</title>
  207. <style>
  208. * { box-sizing: border-box; margin: 0; padding: 0; }
  209. body { font-family: Arial, sans-serif; font-size: 13px; color: #1a1a1a; padding: 40px; max-width: 800px; margin: auto; }
  210. h1 { font-size: 22px; font-weight: bold; margin-bottom: 4px; }
  211. h2 { font-size: 15px; font-weight: 600; margin: 24px 0 10px; border-bottom: 1px solid #e5e7eb; padding-bottom: 6px; }
  212. .subtitle { color: #6b7280; font-size: 12px; margin-bottom: 28px; }
  213. .summary { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; margin-bottom: 24px; }
  214. .card { border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px; text-align: center; }
  215. .card .label { font-size: 11px; color: #6b7280; margin-bottom: 4px; }
  216. .card .value { font-size: 20px; font-weight: bold; }
  217. .card.red .value { color: #dc2626; }
  218. .card.blue .value { color: #2563eb; }
  219. .card.green { border-color: #bbf7d0; background: #f0fdf4; }
  220. .card.green .value { color: #15803d; }
  221. .card.orange { border-color: #fed7aa; background: #fff7ed; }
  222. .card.orange .value { color: #c2410c; }
  223. .card .note { font-size: 11px; color: #9ca3af; margin-top: 4px; }
  224. table { width: 100%; border-collapse: collapse; }
  225. table th { text-align: left; padding: 8px 10px; background: #f9fafb; font-size: 12px; color: #374151; }
  226. table td { padding: 8px 10px; border-bottom: 1px solid #f3f4f6; }
  227. td.amount { text-align: right; font-weight: 600; }
  228. td.pct { text-align: right; color: #6b7280; font-size: 11px; }
  229. .tenant-block { border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px; margin-bottom: 12px; }
  230. .tenant-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; }
  231. .tenant-header strong { font-size: 14px; }
  232. .small { font-size: 11px; color: #9ca3af; }
  233. .badge { font-size: 11px; font-weight: 600; padding: 3px 8px; border-radius: 99px; }
  234. .badge-green { background: #dcfce7; color: #15803d; }
  235. .badge-orange { background: #ffedd5; color: #c2410c; }
  236. .inner-table td { padding: 6px 8px; border-bottom: 1px solid #f3f4f6; font-size: 12px; }
  237. .row-green td { background: #f0fdf4; color: #15803d; }
  238. .row-orange td { background: #fff7ed; color: #c2410c; }
  239. .footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid #e5e7eb; font-size: 11px; color: #9ca3af; text-align: center; }
  240. .owner-block { border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px 14px; margin-bottom: 20px; font-size: 12px; line-height: 1.6; }
  241. .owner-label { display: block; font-size: 10px; color: #6b7280; font-weight: 600; margin-bottom: 2px; letter-spacing: 0.05em; }
  242. @media print {
  243. body { padding: 20px; }
  244. .no-print { display: none; }
  245. }
  246. </style>
  247. </head>
  248. <body>
  249. <h1>Bilan annuel de charges ${bilan.year}</h1>
  250. <p class="subtitle">${bilan.property?.name} — ${bilan.property?.address || ''}</p>
  251. ${ownerBlock}
  252. <div class="summary">
  253. <div class="label">Charges réelles</div>
  254. <div class="value red">${fmtEur(bilan.total_charges_reelles)}</div>
  255. <div class="note">${bilan.charges_count} facture${bilan.charges_count > 1 ? 's' : ''}</div>
  256. </div>
  257. <div class="card">
  258. <div class="label">Provisions perçues</div>
  259. <div class="value blue">${fmtEur(bilan.total_provisions_percues)}</div>
  260. </div>
  261. <div class="card ${isPos ? 'green' : 'orange'}">
  262. <div class="label">${isPos ? 'Trop-perçu global' : 'Solde insuffisant'}</div>
  263. <div class="value">${isPos ? '+' : '-'}${fmtEur(Math.abs(solde))}</div>
  264. </div>
  265. </div>
  266. ${bilan.charges_by_category.length > 0 ? `
  267. <h2>Répartition des charges par catégorie</h2>
  268. <table>
  269. <thead><tr><th>Catégorie</th><th style="text-align:right">Montant</th><th style="text-align:right">%</th></tr></thead>
  270. <tbody>${catRows}</tbody>
  271. <tfoot><tr><td><strong>Total</strong></td><td class="amount"><strong>${fmtEur(bilan.total_charges_reelles)}</strong></td><td></td></tr></tfoot>
  272. </table>` : ''}
  273. ${bilan.bilans.length > 0 ? `<h2>Décompte par locataire</h2>${tenantsRows}` : ''}
  274. <div class="footer">Document généré le ${new Date().toLocaleDateString('fr-FR')} — Bilan de régularisation de charges ${bilan.year}</div>
  275. <script>window.onload = () => window.print()<\/script>
  276. </body>
  277. </html>`
  278. const w = window.open('', '_blank')
  279. w.document.write(html)
  280. w.document.close()
  281. }
  282. const downloadPdfLease = async (leaseBilan) => {
  283. if (!filterProperty || !filterYear || !leaseBilan?.lease_id) return
  284. setPdfLoadingLeaseId(leaseBilan.lease_id)
  285. try {
  286. const token = localStorage.getItem('token')
  287. const resp = await fetch(`/api/charges/bilan/${filterProperty}/${filterYear}/pdf?lease_id=${leaseBilan.lease_id}`, {
  288. headers: { Authorization: `Bearer ${token}` }
  289. })
  290. if (!resp.ok) throw new Error()
  291. const blob = await resp.blob()
  292. const url = window.URL.createObjectURL(blob)
  293. const a = document.createElement('a')
  294. a.href = url
  295. const tenantSlug = leaseBilan.tenant_name.replace(/[^a-z0-9]/gi, '_')
  296. a.download = `decompte_charges_${filterYear}_${tenantSlug}.pdf`
  297. a.click()
  298. window.URL.revokeObjectURL(url)
  299. } catch { toast.error('Erreur lors de la génération du PDF') }
  300. finally { setPdfLoadingLeaseId(null) }
  301. }
  302. const cancelLeaseExercise = async (leaseBilan) => {
  303. if (!filterProperty || !filterYear || !leaseBilan?.lease_id) return
  304. const appliedWarning = leaseBilan.applied_payment_id
  305. ? '\n\n⚠️ Cette régularisation a déjà été appliquée à un paiement. Ce paiement sera corrigé (montant de régularisation remis à 0).'
  306. : ''
  307. if (!confirm(
  308. `Annuler la clôture du bilan annuel des charges ${filterYear} pour ${leaseBilan.tenant_name} ?` +
  309. appliedWarning +
  310. '\n\nCette action est irréversible.'
  311. )) return
  312. setCancelingLeaseId(leaseBilan.lease_id)
  313. try {
  314. await api.delete(`/charges/bilan/${filterProperty}/${filterYear}/close/${leaseBilan.lease_id}`)
  315. toast.success(`Clôture annulée pour ${leaseBilan.tenant_name} (${filterYear})`)
  316. await loadBilan()
  317. } catch (err) {
  318. toast.error(err.response?.data?.error || 'Erreur lors de l\'annulation')
  319. } finally {
  320. setCancelingLeaseId(null)
  321. }
  322. }
  323. const closeLeaseExercise = async (leaseBilan) => {
  324. if (!filterProperty || !filterYear || !leaseBilan?.lease_id) return
  325. if (!confirm(
  326. `Clôturer le bilan annuel des charges ${filterYear} pour ${leaseBilan.tenant_name} ?\n\n` +
  327. `Le calcul se fera au prorata d'occupation (${leaseBilan.occupied_days}/${leaseBilan.year_days} jours).\n\n` +
  328. `Cette opération est définitive.`
  329. )) return
  330. setClosingLeaseId(leaseBilan.lease_id)
  331. try {
  332. const { data } = await api.post(`/charges/bilan/${filterProperty}/${filterYear}/close`, {
  333. lease_id: leaseBilan.lease_id
  334. })
  335. const reg = data.regularizations?.[0]
  336. if (reg) {
  337. toast.success(
  338. `Bilan annuel clôturé (${filterYear}) : ${reg.tenant} | Prorata ${reg.occupied_days}/${reg.year_days} jours | ` +
  339. `${reg.trop_percu >= 0 ? '-' : '+'}${Math.abs(reg.trop_percu).toFixed(2)} € sur le prochain loyer`,
  340. { duration: 6000 }
  341. )
  342. } else {
  343. toast.success(`Bilan annuel clôturé pour ${filterYear}`)
  344. }
  345. await loadBilan()
  346. } catch (err) {
  347. toast.error(err.response?.data?.error || 'Erreur lors de la clôture')
  348. } finally {
  349. setClosingLeaseId(null)
  350. }
  351. }
  352. const fmt = v => `${Number(v).toFixed(2)} €`
  353. const fmtDate = d => d ? new Date(d + 'T00:00:00').toLocaleDateString('fr-FR') : '—'
  354. const totalAmount = charges.reduce((s, c) => s + c.amount, 0)
  355. return (
  356. <div>
  357. <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
  358. <div>
  359. <h1 className="text-2xl font-bold text-gray-900">🧾 Gestion des charges</h1>
  360. <p className="text-gray-500 text-sm mt-1">Charges réelles par immeuble et bilan annuel</p>
  361. </div>
  362. <div className="flex flex-wrap gap-2">
  363. <button onClick={loadBilan} disabled={!filterProperty || bilanLoading}
  364. className="btn-secondary flex items-center gap-2 text-sm">
  365. {bilanLoading ? '...' : '📊 Bilan annuel'}
  366. </button>
  367. <button onClick={openAdd} className="btn-primary text-sm">+ Ajouter une charge</button>
  368. </div>
  369. </div>
  370. {/* Filters */}
  371. <div className="card mb-6 flex flex-wrap gap-4">
  372. <div className="flex-1 min-w-48">
  373. <label className="label">Immeuble / Bien</label>
  374. <select className="input" value={filterProperty} onChange={e => setFilterProperty(e.target.value)}>
  375. <option value="">Tous les biens</option>
  376. {properties.map(p => <option key={p.id} value={p.id}>{p.name} — {p.address}</option>)}
  377. </select>
  378. </div>
  379. <div className="w-36">
  380. <label className="label">Année</label>
  381. <select className="input" value={filterYear} onChange={e => setFilterYear(e.target.value)}>
  382. {YEARS.map(y => <option key={y} value={y}>{y}</option>)}
  383. </select>
  384. </div>
  385. {filterProperty && filterYear && (
  386. <div className="flex items-end">
  387. <div className="bg-blue-50 border border-blue-200 rounded-lg px-4 py-2 text-sm">
  388. <span className="text-blue-500">Total charges :</span>
  389. <span className="text-blue-800 font-bold ml-2">{fmt(totalAmount)}</span>
  390. <span className="text-blue-400 ml-2">({charges.length} ligne{charges.length > 1 ? 's' : ''})</span>
  391. </div>
  392. </div>
  393. )}
  394. </div>
  395. {/* Deductible summary table (10 years) */}
  396. {filterProperty && deductibleSummary && (
  397. <div className="card mb-6">
  398. <button
  399. onClick={() => setShowDeductible(v => !v)}
  400. className="w-full flex items-center justify-between text-left"
  401. >
  402. <div className="flex items-center gap-2">
  403. <span className="text-lg">💼</span>
  404. <div>
  405. <h3 className="text-sm font-semibold text-purple-800">Charges déductibles des impôts — 10 ans</h3>
  406. <p className="text-xs text-purple-500">
  407. Total cumulé : <span className="font-bold">{fmt(deductibleSummary.grand_total)}</span>
  408. </p>
  409. </div>
  410. </div>
  411. <span className={`text-gray-400 transition-transform ${showDeductible ? 'rotate-180' : ''}`}>▼</span>
  412. </button>
  413. {showDeductible && (
  414. <div className="mt-4 overflow-x-auto">
  415. <table className="w-full text-sm">
  416. <thead>
  417. <tr className="bg-purple-50 border-b border-purple-100">
  418. <th className="text-left px-4 py-2 text-purple-700 font-semibold">Année</th>
  419. <th className="text-right px-4 py-2 text-purple-700 font-semibold">Montant déductible</th>
  420. <th className="text-right px-4 py-2 text-purple-700 font-semibold">Nb factures</th>
  421. </tr>
  422. </thead>
  423. <tbody className="divide-y divide-purple-50">
  424. {deductibleSummary.years.map(y => (
  425. <tr key={y.year} className={`hover:bg-purple-50/50 ${String(y.year) === filterYear ? 'bg-purple-50 font-semibold' : ''}`}>
  426. <td className="px-4 py-2 text-gray-700">{y.year}</td>
  427. <td className={`px-4 py-2 text-right ${y.total > 0 ? 'text-purple-700' : 'text-gray-300'}`}>
  428. {y.total > 0 ? fmt(y.total) : '—'}
  429. </td>
  430. <td className={`px-4 py-2 text-right ${y.count > 0 ? 'text-gray-600' : 'text-gray-300'}`}>
  431. {y.count > 0 ? y.count : '—'}
  432. </td>
  433. </tr>
  434. ))}
  435. </tbody>
  436. <tfoot className="bg-purple-50 border-t-2 border-purple-200">
  437. <tr>
  438. <td className="px-4 py-2 font-bold text-purple-800">Total (10 ans)</td>
  439. <td className="px-4 py-2 text-right font-bold text-purple-800">{fmt(deductibleSummary.grand_total)}</td>
  440. <td className="px-4 py-2 text-right font-bold text-gray-600">
  441. {deductibleSummary.years.reduce((s, y) => s + y.count, 0)}
  442. </td>
  443. </tr>
  444. </tfoot>
  445. </table>
  446. </div>
  447. )}
  448. </div>
  449. )}
  450. {/* Charges list */}
  451. {charges.length === 0 ? (
  452. <div className="card text-center py-12">
  453. <p className="text-4xl mb-3">🧾</p>
  454. <p className="text-gray-500">Aucune charge enregistrée pour cette période.</p>
  455. <button onClick={openAdd} className="btn-primary mt-4">Ajouter une première charge</button>
  456. </div>
  457. ) : (
  458. <div>
  459. {/* Desktop table */}
  460. <div className="card overflow-hidden p-0 hidden md:block">
  461. <table className="w-full">
  462. <thead className="bg-gray-50 border-b">
  463. <tr>
  464. <th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Date</th>
  465. <th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Catégorie</th>
  466. <th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Libellé</th>
  467. <th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Immeuble</th>
  468. <th className="text-right px-6 py-3 text-sm font-semibold text-gray-600">Montant</th>
  469. <th className="text-center px-6 py-3 text-sm font-semibold text-gray-600">Récup.</th>
  470. <th className="text-center px-6 py-3 text-sm font-semibold text-gray-600">Facture</th>
  471. <th className="px-6 py-3"></th>
  472. </tr>
  473. </thead>
  474. <tbody className="divide-y divide-gray-50">
  475. {charges.map(c => (
  476. <tr key={c.id} className="hover:bg-gray-50">
  477. <td className="px-6 py-4 text-sm text-gray-600">{fmtDate(c.date)}</td>
  478. <td className="px-6 py-4"><CategoryBadge value={c.category} /></td>
  479. <td className="px-6 py-4 text-gray-900 font-medium">{c.label}</td>
  480. <td className="px-6 py-4 text-sm text-gray-500">{c.property_name}</td>
  481. <td className="px-6 py-4 text-right font-semibold text-gray-900">{fmt(c.amount)}</td>
  482. <td className="px-6 py-4 text-center">
  483. {c.recoverable_type === 'recoverable'
  484. ? <span className="text-xs bg-green-100 text-green-700 font-semibold px-2 py-0.5 rounded-full">✓ Locataire</span>
  485. : c.recoverable_type === 'deductible'
  486. ? <span className="text-xs bg-purple-100 text-purple-700 font-semibold px-2 py-0.5 rounded-full">💼 Déductible</span>
  487. : <span className="text-xs bg-gray-100 text-gray-500 font-semibold px-2 py-0.5 rounded-full">✗ Aucun</span>}
  488. </td>
  489. <td className="px-6 py-4 text-center">
  490. {c.invoice_path ? (
  491. <button onClick={() => downloadInvoice(c)} className="text-green-600 hover:text-green-800 text-sm font-medium" title={c.invoice_name}>
  492. 📎 {c.invoice_name?.length > 20 ? c.invoice_name.slice(0, 17) + '…' : c.invoice_name}
  493. </button>
  494. ) : <span className="text-gray-300 text-xs">—</span>}
  495. </td>
  496. <td className="px-6 py-4 text-right space-x-2 whitespace-nowrap">
  497. <button onClick={() => openEdit(c)} className="text-blue-500 hover:text-blue-700 text-sm">✏️</button>
  498. <button onClick={() => handleDelete(c.id)} className="text-gray-400 hover:text-red-600 ml-1">🗑️</button>
  499. </td>
  500. </tr>
  501. ))}
  502. </tbody>
  503. <tfoot className="bg-gray-50 border-t-2 border-gray-200">
  504. <tr>
  505. <td colSpan={5} className="px-6 py-3 text-sm font-semibold text-gray-700">Total</td>
  506. <td className="px-6 py-3 text-right font-bold text-gray-900">{fmt(totalAmount)}</td>
  507. <td colSpan={2}></td>
  508. </tr>
  509. </tfoot>
  510. </table>
  511. </div>
  512. {/* Mobile cards */}
  513. <div className="md:hidden space-y-3">
  514. {charges.map(c => (
  515. <div key={c.id} className="card">
  516. <div className="flex items-start justify-between mb-1">
  517. <div className="flex-1 min-w-0">
  518. <p className="font-semibold text-gray-900 truncate">{c.label}</p>
  519. <div className="flex items-center gap-2 mt-1 flex-wrap">
  520. <CategoryBadge value={c.category} />
  521. {c.recoverable_type === 'recoverable'
  522. ? <span className="text-xs bg-green-100 text-green-700 px-1.5 py-0.5 rounded-full font-medium">Locataire</span>
  523. : c.recoverable_type === 'deductible'
  524. ? <span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded-full font-medium">Déductible</span>
  525. : <span className="text-xs bg-gray-100 text-gray-400 px-1.5 py-0.5 rounded-full font-medium">Aucun</span>}
  526. </div>
  527. </div>
  528. <p className="font-bold text-gray-900 text-lg ml-3 shrink-0">{fmt(c.amount)}</p>
  529. </div>
  530. <p className="text-sm text-gray-500">{fmtDate(c.date)}</p>
  531. <div className="flex items-center justify-between mt-3 pt-3 border-t border-gray-50">
  532. {c.invoice_path
  533. ? <button onClick={() => downloadInvoice(c)} className="text-green-600 text-sm">📎 Facture</button>
  534. : <span />}
  535. <div className="flex gap-3">
  536. <button onClick={() => openEdit(c)} className="text-blue-500 text-sm">✏️ Modifier</button>
  537. <button onClick={() => handleDelete(c.id)} className="text-red-400 text-sm">🗑️</button>
  538. </div>
  539. </div>
  540. </div>
  541. ))}
  542. <div className="card bg-gray-50 flex justify-between items-center">
  543. <span className="font-semibold text-gray-700">Total</span>
  544. <span className="font-bold text-gray-900">{fmt(totalAmount)}</span>
  545. </div>
  546. </div>
  547. </div>
  548. )}
  549. {/* Add/Edit Modal */}
  550. {showModal && (
  551. <Modal title={editCharge ? 'Modifier la charge' : 'Ajouter une charge'} onClose={() => setShowModal(false)}>
  552. <form onSubmit={handleSubmit} className="space-y-4">
  553. <div>
  554. <label className="label">Immeuble / Bien *</label>
  555. <select className="input" required value={form.property_id}
  556. onChange={e => setForm(f => ({ ...f, property_id: e.target.value }))}>
  557. <option value="">— Sélectionner —</option>
  558. {properties.map(p => <option key={p.id} value={p.id}>{p.name} — {p.address}</option>)}
  559. </select>
  560. </div>
  561. <div className="grid grid-cols-2 gap-4">
  562. <div>
  563. <label className="label">Catégorie *</label>
  564. <select className="input" value={form.category}
  565. onChange={e => setForm(f => ({ ...f, category: e.target.value }))}>
  566. {CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
  567. </select>
  568. </div>
  569. <div>
  570. <label className="label">Année *</label>
  571. <select className="input" value={form.year}
  572. onChange={e => setForm(f => ({ ...f, year: e.target.value }))}>
  573. {YEARS.map(y => <option key={y} value={y}>{y}</option>)}
  574. </select>
  575. </div>
  576. </div>
  577. <div>
  578. <label className="label">Libellé *</label>
  579. <input className="input" required value={form.label}
  580. onChange={e => setForm(f => ({ ...f, label: e.target.value }))}
  581. placeholder="Ex : Facture eau T1 2024" />
  582. </div>
  583. <div className="grid grid-cols-2 gap-4">
  584. <div>
  585. <label className="label">Montant (€) *</label>
  586. <input className="input" type="number" step="0.01" min="0" required value={form.amount}
  587. onChange={e => setForm(f => ({ ...f, amount: e.target.value }))} />
  588. </div>
  589. <div>
  590. <label className="label">Date de la dépense *</label>
  591. <input className="input" type="date" required value={form.date}
  592. onChange={e => setForm(f => ({ ...f, date: e.target.value }))} />
  593. </div>
  594. </div>
  595. <div>
  596. <label className="label">Notes (optionnel)</label>
  597. <input className="input" value={form.notes}
  598. onChange={e => setForm(f => ({ ...f, notes: e.target.value }))}
  599. placeholder="Numéro de facture, prestataire…" />
  600. </div>
  601. <div>
  602. <label className="label">Affectation de la charge</label>
  603. <div className="space-y-2 mt-1">
  604. {[
  605. { value: 'recoverable', label: '✓ Récupérable auprès du locataire', sub: 'Incluse dans le bilan de régularisation annuel', color: 'green' },
  606. { value: 'deductible', label: '💼 Déductible des impôts', sub: 'Charge du propriétaire déductible fiscalement', color: 'purple' },
  607. { value: 'none', label: '✗ Aucun des deux', sub: 'Charge non récupérable et non déductible', color: 'gray' },
  608. ].map(opt => (
  609. <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'}`}>
  610. <input type="radio" name="recoverable_type" value={opt.value}
  611. checked={form.recoverable_type === opt.value}
  612. onChange={e => setForm(f => ({ ...f, recoverable_type: e.target.value }))}
  613. className="mt-0.5 accent-blue-600" />
  614. <div>
  615. <p className="text-sm font-medium text-gray-800">{opt.label}</p>
  616. <p className="text-xs text-gray-400">{opt.sub}</p>
  617. </div>
  618. </label>
  619. ))}
  620. </div>
  621. </div>
  622. <div>
  623. <label className="label">Facture (PDF, JPG, PNG — max 10 Mo)</label>
  624. {editCharge?.invoice_name && !form.invoice && (
  625. <p className="text-xs text-green-600 mb-1">📎 Fichier actuel : {editCharge.invoice_name}</p>
  626. )}
  627. <input className="input" type="file" accept=".pdf,.jpg,.jpeg,.png,.webp"
  628. onChange={e => setForm(f => ({ ...f, invoice: e.target.files[0] || null }))} />
  629. </div>
  630. <div className="flex gap-3 pt-2">
  631. <button type="button" onClick={() => setShowModal(false)} className="btn-secondary flex-1">Annuler</button>
  632. <button type="submit" disabled={loading} className="btn-primary flex-1">
  633. {loading ? '...' : editCharge ? 'Mettre à jour' : 'Ajouter'}
  634. </button>
  635. </div>
  636. </form>
  637. </Modal>
  638. )}
  639. {/* Bilan annuel Modal */}
  640. {showBilan && bilan && (
  641. <Modal title={`📊 Bilan annuel ${bilan.year} — ${bilan.property?.name}`} onClose={() => setShowBilan(false)}>
  642. <div className="space-y-6">
  643. <div className="flex justify-end gap-2 flex-wrap">
  644. <button onClick={printBilan}
  645. className="btn-secondary flex items-center gap-2 text-sm">
  646. 🖨️ Aperçu / Imprimer
  647. </button>
  648. </div>
  649. {/* Summary cards */}
  650. <div className="grid grid-cols-2 gap-3">
  651. <div className="bg-red-50 border border-red-100 rounded-xl p-4 text-center">
  652. <p className="text-xs text-red-500 font-medium mb-1">Charges récupérables</p>
  653. <p className="text-2xl font-bold text-red-700">{fmt(bilan.total_charges_reelles)}</p>
  654. <p className="text-xs text-red-400 mt-1">{bilan.charges_count} facture{bilan.charges_count > 1 ? 's' : ''}</p>
  655. </div>
  656. <div className="bg-blue-50 border border-blue-100 rounded-xl p-4 text-center">
  657. <p className="text-xs text-blue-500 font-medium mb-1">Provisions perçues</p>
  658. <p className="text-2xl font-bold text-blue-700">{fmt(bilan.total_provisions_percues)}</p>
  659. <p className="text-xs text-blue-400 mt-1">de tous les locataires</p>
  660. </div>
  661. </div>
  662. {/* Charges déductibles (info) */}
  663. {bilan.total_deductible > 0 && (
  664. <div className="bg-purple-50 border border-purple-200 rounded-xl p-3 flex items-center justify-between text-sm">
  665. <span className="text-purple-700">💼 Charges déductibles des impôts (à votre charge)</span>
  666. <span className="font-semibold text-purple-800">{fmt(bilan.total_deductible)}</span>
  667. </div>
  668. )}
  669. {/* Charges non récupérables (info) */}
  670. {bilan.total_non_recoverable > 0 && (
  671. <div className="bg-gray-50 border border-gray-200 rounded-xl p-3 flex items-center justify-between text-sm">
  672. <span className="text-gray-500">✗ Sans affectation (à votre charge, non déductible)</span>
  673. <span className="font-semibold text-gray-700">{fmt(bilan.total_non_recoverable)}</span>
  674. </div>
  675. )}
  676. {/* Solde global */}
  677. {(() => {
  678. const solde = bilan.total_provisions_percues - bilan.total_charges_reelles
  679. const isPositive = solde >= 0
  680. return (
  681. <div className={`rounded-xl p-4 text-center border ${isPositive ? 'bg-green-50 border-green-200' : 'bg-orange-50 border-orange-200'}`}>
  682. <p className={`text-sm font-medium ${isPositive ? 'text-green-600' : 'text-orange-600'}`}>
  683. {isPositive ? '✅ Trop-perçu global (à restituer)' : '⚠️ Solde insuffisant (à appeler)'}
  684. </p>
  685. <p className={`text-3xl font-bold mt-1 ${isPositive ? 'text-green-700' : 'text-orange-700'}`}>
  686. {isPositive ? '+' : ''}{fmt(solde)}
  687. </p>
  688. </div>
  689. )
  690. })()}
  691. {/* Charges par catégorie */}
  692. {bilan.charges_by_category.length > 0 && (
  693. <div>
  694. <h3 className="text-sm font-semibold text-gray-700 mb-2">Répartition par catégorie</h3>
  695. <div className="space-y-1">
  696. {bilan.charges_by_category.map(c => {
  697. const cat = CATEGORIES.find(x => x.value === c.category)
  698. const pct = bilan.total_charges_reelles > 0 ? (c.total / bilan.total_charges_reelles * 100).toFixed(1) : 0
  699. return (
  700. <div key={c.category} className="flex items-center justify-between text-sm py-1">
  701. <span className="text-gray-600">{cat?.label || c.category}</span>
  702. <div className="flex items-center gap-3">
  703. <div className="w-24 bg-gray-100 rounded-full h-1.5">
  704. <div className="bg-blue-500 h-1.5 rounded-full" style={{ width: `${pct}%` }}></div>
  705. </div>
  706. <span className="text-gray-500 w-8 text-right text-xs">{pct}%</span>
  707. <span className="font-medium text-gray-900 w-20 text-right">{fmt(c.total)}</span>
  708. </div>
  709. </div>
  710. )
  711. })}
  712. </div>
  713. </div>
  714. )}
  715. {/* Par locataire */}
  716. {bilan.bilans.length > 0 ? (
  717. <div>
  718. <h3 className="text-sm font-semibold text-gray-700 mb-3">Décompte par locataire (prorata d'occupation)</h3>
  719. <div className="space-y-3">
  720. {bilan.bilans.map(b => {
  721. const isPos = b.trop_percu >= 0
  722. return (
  723. <div key={b.lease_id} className="border rounded-xl p-4">
  724. <div className="flex items-start justify-between mb-3">
  725. <div>
  726. <p className="font-semibold text-gray-900">{b.tenant_name}</p>
  727. {b.tenant_email && <p className="text-xs text-gray-400">{b.tenant_email}</p>}
  728. </div>
  729. <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'}`}>
  730. {isPos ? '↩ Remboursement' : '↑ Appel de fonds'}
  731. </span>
  732. </div>
  733. <div className="mb-3 text-xs text-gray-500 bg-gray-50 rounded-lg px-3 py-2">
  734. 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>
  735. </div>
  736. <div className="grid grid-cols-3 gap-2 text-sm">
  737. <div className="bg-gray-50 rounded-lg p-2 text-center">
  738. <p className="text-xs text-gray-500">Provisions perçues</p>
  739. <p className="font-semibold text-gray-800">{fmt(b.provisions_percues)}</p>
  740. </div>
  741. <div className="bg-gray-50 rounded-lg p-2 text-center">
  742. <p className="text-xs text-gray-500">Quote-part réelle (prorata)</p>
  743. <p className="font-semibold text-gray-800">{fmt(b.quote_part_charges)}</p>
  744. </div>
  745. <div className={`rounded-lg p-2 text-center ${isPos ? 'bg-green-50' : 'bg-orange-50'}`}>
  746. <p className={`text-xs font-medium ${isPos ? 'text-green-600' : 'text-orange-600'}`}>
  747. {isPos ? 'Trop-perçu' : 'Solde dû'}
  748. </p>
  749. <p className={`font-bold text-lg ${isPos ? 'text-green-700' : 'text-orange-700'}`}>
  750. {fmt(Math.abs(b.trop_percu))}
  751. </p>
  752. </div>
  753. </div>
  754. <div className="mt-3 flex items-center justify-between gap-2 flex-wrap">
  755. <button
  756. onClick={() => downloadPdfLease(b)}
  757. disabled={pdfLoadingLeaseId === b.lease_id}
  758. 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"
  759. >
  760. {pdfLoadingLeaseId === b.lease_id ? '⏳ Génération…' : '📥 Décompte PDF'}
  761. </button>
  762. <div className="flex items-center gap-2 flex-wrap justify-end">
  763. {b.closed ? (
  764. <>
  765. <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>
  766. <button
  767. onClick={() => cancelLeaseExercise(b)}
  768. disabled={cancelingLeaseId === b.lease_id}
  769. 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"
  770. >
  771. {cancelingLeaseId === b.lease_id ? '⏳ Annulation…' : '↩ Annuler la clôture'}
  772. </button>
  773. </>
  774. ) : (
  775. <button
  776. onClick={() => closeLeaseExercise(b)}
  777. disabled={closingLeaseId === b.lease_id}
  778. 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"
  779. >
  780. {closingLeaseId === b.lease_id ? '⏳ Clôture du bilan…' : '✅ Clôturer le bilan annuel'}
  781. </button>
  782. )}
  783. </div>
  784. </div>
  785. </div>
  786. )
  787. })}
  788. </div>
  789. </div>
  790. ) : (
  791. <p className="text-center text-gray-400 text-sm py-4">Aucun locataire trouvé pour cette période.</p>
  792. )}
  793. </div>
  794. </Modal>
  795. )}
  796. </div>
  797. )
  798. }