|
@@ -0,0 +1,246 @@
|
|
|
|
|
+import { useEffect, useState } from 'react'
|
|
|
|
|
+import { useParams, Link } from 'react-router-dom'
|
|
|
|
|
+import api from '../api'
|
|
|
|
|
+import toast from 'react-hot-toast'
|
|
|
|
|
+
|
|
|
|
|
+const MONTHS = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']
|
|
|
|
|
+const METHODS = [
|
|
|
|
|
+ { value: 'virement', label: 'Virement bancaire' },
|
|
|
|
|
+ { value: 'cheque', label: 'Chèque' },
|
|
|
|
|
+ { value: 'especes', label: 'Espèces' },
|
|
|
|
|
+ { value: 'prelevement', label: 'Prélèvement automatique' },
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+function Modal({ title, onClose, children }) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
|
|
|
+ <div className="bg-white rounded-2xl shadow-xl w-full max-w-lg">
|
|
|
|
|
+ <div className="flex items-center justify-between p-6 border-b">
|
|
|
|
|
+ <h2 className="text-lg font-semibold">{title}</h2>
|
|
|
|
|
+ <button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none">×</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="p-6">{children}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export default function LeaseDetail() {
|
|
|
|
|
+ const { id } = useParams()
|
|
|
|
|
+ const [lease, setLease] = useState(null)
|
|
|
|
|
+ const [payments, setPayments] = useState([])
|
|
|
|
|
+ const [showModal, setShowModal] = useState(false)
|
|
|
|
|
+ const [loading, setLoading] = useState(false)
|
|
|
|
|
+
|
|
|
|
|
+ const now = new Date()
|
|
|
|
|
+ const [form, setForm] = useState({
|
|
|
|
|
+ period_month: now.getMonth() + 1,
|
|
|
|
|
+ period_year: now.getFullYear(),
|
|
|
|
|
+ rent_paid: '',
|
|
|
|
|
+ charges_paid: '',
|
|
|
|
|
+ payment_date: now.toISOString().slice(0, 10),
|
|
|
|
|
+ payment_method: 'virement',
|
|
|
|
|
+ notes: ''
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ const load = async () => {
|
|
|
|
|
+ const [l, p] = await Promise.all([api.get(`/leases/${id}`), api.get(`/payments?lease_id=${id}`)])
|
|
|
|
|
+ setLease(l.data)
|
|
|
|
|
+ setPayments(p.data)
|
|
|
|
|
+ setForm(f => ({ ...f, rent_paid: l.data.rent_amount, charges_paid: l.data.charges_amount }))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => { load() }, [id])
|
|
|
|
|
+
|
|
|
|
|
+ const handlePayment = async e => {
|
|
|
|
|
+ e.preventDefault(); setLoading(true)
|
|
|
|
|
+ try {
|
|
|
|
|
+ await api.post('/payments', { ...form, lease_id: Number(id) })
|
|
|
|
|
+ toast.success('Paiement enregistré !')
|
|
|
|
|
+ setShowModal(false); load()
|
|
|
|
|
+ } catch (err) { toast.error(err.response?.data?.error || 'Erreur') }
|
|
|
|
|
+ finally { setLoading(false) }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const handleDeletePayment = async payId => {
|
|
|
|
|
+ if (!confirm('Supprimer ce paiement ?')) return
|
|
|
|
|
+ try { await api.delete(`/payments/${payId}`); toast.success('Paiement supprimé'); load() }
|
|
|
|
|
+ catch { toast.error('Erreur') }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const downloadReceipt = payId => {
|
|
|
|
|
+ const token = localStorage.getItem('token')
|
|
|
|
|
+ const link = document.createElement('a')
|
|
|
|
|
+ link.href = `/api/receipts/${payId}`
|
|
|
|
|
+ link.setAttribute('download', `quittance.pdf`)
|
|
|
|
|
+ // Use fetch to download with auth header
|
|
|
|
|
+ fetch(`/api/receipts/${payId}`, { headers: { Authorization: `Bearer ${token}` } })
|
|
|
|
|
+ .then(r => r.blob())
|
|
|
|
|
+ .then(blob => {
|
|
|
|
|
+ const url = window.URL.createObjectURL(blob)
|
|
|
|
|
+ link.href = url
|
|
|
|
|
+ document.body.appendChild(link)
|
|
|
|
|
+ link.click()
|
|
|
|
|
+ document.body.removeChild(link)
|
|
|
|
|
+ window.URL.revokeObjectURL(url)
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch(() => toast.error('Erreur lors du téléchargement'))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const fmt = v => `${Number(v).toFixed(2)} €`
|
|
|
|
|
+ const fmtDate = d => d ? new Date(d).toLocaleDateString('fr-FR') : '—'
|
|
|
|
|
+
|
|
|
|
|
+ if (!lease) return <div className="flex justify-center py-16"><div className="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600"></div></div>
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div className="mb-6">
|
|
|
|
|
+ <Link to="/leases" className="text-blue-600 hover:underline text-sm">← Retour aux baux</Link>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Bail info */}
|
|
|
|
|
+ <div className="card mb-6">
|
|
|
|
|
+ <div className="flex items-start justify-between">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h1 className="text-2xl font-bold text-gray-900">
|
|
|
|
|
+ {lease.tenant_first_name} {lease.tenant_last_name}
|
|
|
|
|
+ </h1>
|
|
|
|
|
+ <p className="text-gray-600 mt-1">{lease.property_name}</p>
|
|
|
|
|
+ <p className="text-gray-500 text-sm">{lease.property_address}, {lease.property_zip} {lease.property_city}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span className={lease.active ? 'badge-green' : 'badge-red'}>{lease.active ? 'Actif' : 'Clôturé'}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6 pt-6 border-t">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <p className="text-sm text-gray-500">Loyer HC</p>
|
|
|
|
|
+ <p className="font-semibold text-gray-900">{fmt(lease.rent_amount)}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <p className="text-sm text-gray-500">Charges</p>
|
|
|
|
|
+ <p className="font-semibold text-gray-900">{fmt(lease.charges_amount)}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <p className="text-sm text-gray-500">Total mensuel</p>
|
|
|
|
|
+ <p className="font-semibold text-blue-700">{fmt(lease.rent_amount + lease.charges_amount)}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <p className="text-sm text-gray-500">Dépôt de garantie</p>
|
|
|
|
|
+ <p className="font-semibold text-gray-900">{fmt(lease.deposit_amount)}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <p className="text-sm text-gray-500">Début du bail</p>
|
|
|
|
|
+ <p className="font-semibold text-gray-900">{fmtDate(lease.start_date)}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <p className="text-sm text-gray-500">Fin du bail</p>
|
|
|
|
|
+ <p className="font-semibold text-gray-900">{fmtDate(lease.end_date)}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <p className="text-sm text-gray-500">Échéance</p>
|
|
|
|
|
+ <p className="font-semibold text-gray-900">Le {lease.payment_day} du mois</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Paiements */}
|
|
|
|
|
+ <div className="flex items-center justify-between mb-4">
|
|
|
|
|
+ <h2 className="text-xl font-bold text-gray-900">💰 Historique des paiements</h2>
|
|
|
|
|
+ {lease.active && (
|
|
|
|
|
+ <button onClick={() => setShowModal(true)} className="btn-primary">+ Enregistrer un paiement</button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {payments.length === 0 ? (
|
|
|
|
|
+ <div className="card text-center py-10">
|
|
|
|
|
+ <p className="text-gray-400">Aucun paiement enregistré pour ce bail.</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className="card overflow-hidden p-0">
|
|
|
|
|
+ <table className="w-full">
|
|
|
|
|
+ <thead className="bg-gray-50 border-b">
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ <th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Période</th>
|
|
|
|
|
+ <th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Date paiement</th>
|
|
|
|
|
+ <th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Mode</th>
|
|
|
|
|
+ <th className="text-right px-6 py-3 text-sm font-semibold text-gray-600">Loyer</th>
|
|
|
|
|
+ <th className="text-right px-6 py-3 text-sm font-semibold text-gray-600">Charges</th>
|
|
|
|
|
+ <th className="text-right px-6 py-3 text-sm font-semibold text-gray-600">Total</th>
|
|
|
|
|
+ <th className="px-6 py-3"></th>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </thead>
|
|
|
|
|
+ <tbody className="divide-y divide-gray-50">
|
|
|
|
|
+ {payments.map(p => (
|
|
|
|
|
+ <tr key={p.id} className="hover:bg-gray-50">
|
|
|
|
|
+ <td className="px-6 py-4 font-medium text-gray-900">{MONTHS[p.period_month-1]} {p.period_year}</td>
|
|
|
|
|
+ <td className="px-6 py-4 text-gray-600">{fmtDate(p.payment_date)}</td>
|
|
|
|
|
+ <td className="px-6 py-4 text-gray-600 capitalize">{p.payment_method}</td>
|
|
|
|
|
+ <td className="px-6 py-4 text-right text-gray-900">{fmt(p.rent_paid)}</td>
|
|
|
|
|
+ <td className="px-6 py-4 text-right text-gray-600">{fmt(p.charges_paid)}</td>
|
|
|
|
|
+ <td className="px-6 py-4 text-right font-semibold text-green-700">{fmt(p.rent_paid + p.charges_paid)}</td>
|
|
|
|
|
+ <td className="px-6 py-4 text-right space-x-2">
|
|
|
|
|
+ <button onClick={() => downloadReceipt(p.id)} className="text-blue-600 hover:text-blue-800 text-sm font-medium" title="Télécharger la quittance PDF">📄 Quittance</button>
|
|
|
|
|
+ <button onClick={() => handleDeletePayment(p.id)} className="text-gray-400 hover:text-red-600 ml-2">🗑️</button>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Modal paiement */}
|
|
|
|
|
+ {showModal && (
|
|
|
|
|
+ <Modal title="Enregistrer un paiement" onClose={() => setShowModal(false)}>
|
|
|
|
|
+ <form onSubmit={handlePayment} className="space-y-4">
|
|
|
|
|
+ <div className="grid grid-cols-2 gap-4">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="label">Mois *</label>
|
|
|
|
|
+ <select className="input" value={form.period_month} onChange={e => setForm(f => ({ ...f, period_month: Number(e.target.value) }))}>
|
|
|
|
|
+ {MONTHS.map((m, i) => <option key={i} value={i+1}>{m}</option>)}
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="label">Année *</label>
|
|
|
|
|
+ <input className="input" type="number" min="2000" max="2099" value={form.period_year}
|
|
|
|
|
+ onChange={e => setForm(f => ({ ...f, period_year: Number(e.target.value) }))} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="label">Loyer encaissé (€) *</label>
|
|
|
|
|
+ <input className="input" type="number" step="0.01" min="0" required value={form.rent_paid}
|
|
|
|
|
+ onChange={e => setForm(f => ({ ...f, rent_paid: e.target.value }))} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="label">Charges encaissées (€)</label>
|
|
|
|
|
+ <input className="input" type="number" step="0.01" min="0" value={form.charges_paid}
|
|
|
|
|
+ onChange={e => setForm(f => ({ ...f, charges_paid: e.target.value }))} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="label">Date de paiement *</label>
|
|
|
|
|
+ <input className="input" type="date" required value={form.payment_date}
|
|
|
|
|
+ onChange={e => setForm(f => ({ ...f, payment_date: e.target.value }))} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="label">Mode de paiement</label>
|
|
|
|
|
+ <select className="input" value={form.payment_method} onChange={e => setForm(f => ({ ...f, payment_method: e.target.value }))}>
|
|
|
|
|
+ {METHODS.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="col-span-2">
|
|
|
|
|
+ <label className="label">Notes (optionnel)</label>
|
|
|
|
|
+ <input className="input" value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} placeholder="..." />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="bg-blue-50 rounded-lg p-3 text-sm text-blue-700">
|
|
|
|
|
+ <strong>Total à encaisser :</strong> {fmt(Number(form.rent_paid || 0) + Number(form.charges_paid || 0))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex gap-3 pt-2">
|
|
|
|
|
+ <button type="button" onClick={() => setShowModal(false)} className="btn-secondary flex-1">Annuler</button>
|
|
|
|
|
+ <button type="submit" disabled={loading} className="btn-primary flex-1">{loading ? '...' : 'Enregistrer'}</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </form>
|
|
|
|
|
+ </Modal>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|