Charges.jsx 36 KB

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