Charges.jsx 42 KB

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