feat: add rental income dashboard with comprehensive analytics

- Add new /api/revenus endpoints for rental income data
  - GET /summary: KPIs, monthly trends, breakdown by property, top unpaid
  - GET /details: detailed rental records with filtering
  - GET /by-lot: revenue aggregation by unit
  - GET /immeubles: properties list with revenue stats

- Add RevenusPage with full dashboard layout
  - 6 KPI cards: total revenue, payments, unpaid, collection rate, active tenants, occupied units
  - Monthly evolution chart (rent, payments, unpaid)
  - Property breakdown donut chart
  - Top unpaid tenants list
  - Detailed revenue table with pagination and CSV export

- Add filters panel (property, type, dates, unpaid only, history months)
- Display property address instead of code in all components
- Add navigation link in header (Revenus)
This commit is contained in:
2026-01-20 10:05:57 +01:00
parent 281f69f39e
commit 981fabcc27
12 changed files with 1578 additions and 1 deletions

View File

@@ -0,0 +1,173 @@
<template>
<div class="flex-1 overflow-auto p-6 bg-gray-800">
<div class="max-w-7xl mx-auto space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold text-white">Dashboard Revenus Locatifs</h1>
<p class="text-sm text-gray-400 mt-1">
Suivi et analyse des loyers, reglements et impayes
</p>
</div>
<div class="flex items-center gap-4">
<div v-if="lastUpdate" class="text-xs text-gray-500">
Derniere mise a jour: {{ lastUpdate }}
</div>
<button
@click="loadData"
:disabled="isLoading"
class="px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors disabled:opacity-50"
>
<span v-if="isLoading">Chargement...</span>
<span v-else>Actualiser</span>
</button>
</div>
</div>
<!-- Filters -->
<RevenusFilterPanel @filter-change="onFiltersChange" />
<!-- KPI Cards -->
<RevenusKpiCards :kpis="summary.kpis" />
<!-- Charts Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<RevenusMonthlyChart :data="summary.by_month" />
<RevenusImmeubleChart :data="summary.by_immeuble" />
</div>
<!-- Impayes Section -->
<TopImpayesList :data="summary.top_impayes" />
<!-- Detailed Table -->
<RevenusTable
:revenus="revenus"
:is-loading="isLoadingRevenus"
/>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import RevenusFilterPanel from '../components/revenus/RevenusFilterPanel.vue'
import RevenusKpiCards from '../components/revenus/RevenusKpiCards.vue'
import RevenusMonthlyChart from '../components/revenus/RevenusMonthlyChart.vue'
import RevenusImmeubleChart from '../components/revenus/RevenusImmeubleChart.vue'
import TopImpayesList from '../components/revenus/TopImpayesList.vue'
import RevenusTable from '../components/revenus/RevenusTable.vue'
// Current filters state
let currentFilters = {
immeuble_id: null,
type_ligne: null,
date_debut: null,
date_fin: null,
impayes_only: false,
months: 12
}
const summary = ref({
kpis: {
total_revenus: 0,
total_loyers: 0,
total_taxes: 0,
total_provisions: 0,
total_regles: 0,
total_impayes: 0,
taux_recouvrement: 0,
nb_locataires_actifs: 0,
nb_lots_occupes: 0
},
by_month: [],
by_immeuble: [],
top_impayes: []
})
const revenus = ref([])
const isLoading = ref(false)
const isLoadingRevenus = ref(false)
const lastUpdate = ref(null)
// Debounce timer
let debounceTimer = null
function onFiltersChange(newFilters) {
currentFilters = { ...newFilters }
// Debounce
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
loadData()
}, 300)
}
function buildSummaryParams() {
const params = new URLSearchParams()
if (currentFilters.months) params.append('months', currentFilters.months)
if (currentFilters.immeuble_id !== null) params.append('immeuble_id', currentFilters.immeuble_id)
return params.toString()
}
function buildDetailsParams() {
const params = new URLSearchParams()
if (currentFilters.immeuble_id !== null) params.append('immeuble_id', currentFilters.immeuble_id)
if (currentFilters.type_ligne !== null) params.append('type_ligne', currentFilters.type_ligne)
if (currentFilters.date_debut) params.append('date_debut', currentFilters.date_debut)
if (currentFilters.date_fin) params.append('date_fin', currentFilters.date_fin)
if (currentFilters.impayes_only) params.append('impayes_only', 'true')
params.append('limit', '500')
return params.toString()
}
async function loadData() {
isLoading.value = true
isLoadingRevenus.value = true
const summaryParams = buildSummaryParams()
const detailsParams = buildDetailsParams()
const summaryUrl = summaryParams ? `/api/revenus/summary?${summaryParams}` : '/api/revenus/summary'
const detailsUrl = detailsParams ? `/api/revenus/details?${detailsParams}` : '/api/revenus/details'
console.log('Loading revenus data with filters:', currentFilters)
try {
const [summaryRes, detailsRes] = await Promise.all([
fetch(summaryUrl),
fetch(detailsUrl)
])
if (summaryRes.ok) {
summary.value = await summaryRes.json()
console.log('Summary loaded:', summary.value)
} else {
console.error('Failed to load summary:', await summaryRes.text())
}
if (detailsRes.ok) {
revenus.value = await detailsRes.json()
console.log('Details loaded:', revenus.value.length, 'revenus')
} else {
console.error('Failed to load details:', await detailsRes.text())
}
lastUpdate.value = new Date().toLocaleTimeString('fr-FR')
} catch (err) {
console.error('Failed to load revenus data:', err)
} finally {
isLoading.value = false
isLoadingRevenus.value = false
}
}
onMounted(() => {
loadData()
})
</script>