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:
@@ -18,12 +18,20 @@
|
||||
Accueil
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/revenus"
|
||||
class="px-3 py-1.5 text-sm rounded transition-colors"
|
||||
:class="$route.path === '/revenus' ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'"
|
||||
>
|
||||
Revenus
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/analytics"
|
||||
class="px-3 py-1.5 text-sm rounded transition-colors"
|
||||
:class="$route.path === '/analytics' ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'"
|
||||
>
|
||||
Analyses
|
||||
Depenses
|
||||
</router-link>
|
||||
|
||||
<!-- Import button with file input -->
|
||||
|
||||
154
frontend/src/components/revenus/RevenusFilterPanel.vue
Normal file
154
frontend/src/components/revenus/RevenusFilterPanel.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-medium text-white">Filtres</h3>
|
||||
<button
|
||||
@click="resetFilters"
|
||||
class="text-xs text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
Reinitialiser
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
<!-- Immeuble -->
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Immeuble</label>
|
||||
<select
|
||||
v-model="filters.immeuble_id"
|
||||
@change="emitFilters"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option :value="null">Tous</option>
|
||||
<option
|
||||
v-for="immeuble in immeubles"
|
||||
:key="immeuble.immeuble_id"
|
||||
:value="immeuble.immeuble_id"
|
||||
>
|
||||
{{ getImmeubleName(immeuble) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Type ligne -->
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Type</label>
|
||||
<select
|
||||
v-model="filters.type_ligne"
|
||||
@change="emitFilters"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option :value="null">Tous</option>
|
||||
<option value="loyer">Loyer</option>
|
||||
<option value="solde_anterieur">Solde anterieur</option>
|
||||
<option value="rappel_loyer">Rappel loyer</option>
|
||||
<option value="divers">Divers</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Date debut -->
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Date debut</label>
|
||||
<input
|
||||
type="date"
|
||||
v-model="filters.date_debut"
|
||||
@change="emitFilters"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Date fin -->
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Date fin</label>
|
||||
<input
|
||||
type="date"
|
||||
v-model="filters.date_fin"
|
||||
@change="emitFilters"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Impayes only -->
|
||||
<div class="flex items-end">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="filters.impayes_only"
|
||||
@change="emitFilters"
|
||||
class="w-4 h-4 rounded bg-gray-800 border-gray-700 text-amber-500 focus:ring-amber-500 focus:ring-offset-gray-900"
|
||||
/>
|
||||
<span class="text-sm text-gray-300">Impayes uniquement</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Months slider -->
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Historique: {{ filters.months }} mois</label>
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="filters.months"
|
||||
@change="emitFilters"
|
||||
min="3"
|
||||
max="24"
|
||||
step="3"
|
||||
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const emit = defineEmits(['filter-change'])
|
||||
|
||||
const immeubles = ref([])
|
||||
|
||||
const filters = ref({
|
||||
immeuble_id: null,
|
||||
type_ligne: null,
|
||||
date_debut: null,
|
||||
date_fin: null,
|
||||
impayes_only: false,
|
||||
months: 12
|
||||
})
|
||||
|
||||
function emitFilters() {
|
||||
emit('filter-change', { ...filters.value })
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.value = {
|
||||
immeuble_id: null,
|
||||
type_ligne: null,
|
||||
date_debut: null,
|
||||
date_fin: null,
|
||||
impayes_only: false,
|
||||
months: 12
|
||||
}
|
||||
emitFilters()
|
||||
}
|
||||
|
||||
function getImmeubleName(immeuble) {
|
||||
if (immeuble.adresse) {
|
||||
return immeuble.adresse
|
||||
}
|
||||
return immeuble.immeuble_code
|
||||
}
|
||||
|
||||
async function loadImmeubles() {
|
||||
try {
|
||||
const res = await fetch('/api/revenus/immeubles')
|
||||
if (res.ok) {
|
||||
immeubles.value = await res.json()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load immeubles:', err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadImmeubles()
|
||||
})
|
||||
</script>
|
||||
130
frontend/src/components/revenus/RevenusImmeubleChart.vue
Normal file
130
frontend/src/components/revenus/RevenusImmeubleChart.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-medium text-white">Revenus par immeuble</h3>
|
||||
<span class="text-xs text-gray-500">{{ data.length }} immeubles</span>
|
||||
</div>
|
||||
|
||||
<div v-if="data.length === 0" class="h-64 flex items-center justify-center text-gray-500">
|
||||
Aucune donnee disponible
|
||||
</div>
|
||||
|
||||
<div v-else class="h-64">
|
||||
<Doughnut :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div v-if="data.length > 0" class="mt-4 grid grid-cols-2 gap-2">
|
||||
<div
|
||||
v-for="(item, index) in data.slice(0, 6)"
|
||||
:key="item.immeuble_id"
|
||||
class="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<span
|
||||
class="w-3 h-3 rounded-sm flex-shrink-0"
|
||||
:style="{ backgroundColor: getColor(index) }"
|
||||
></span>
|
||||
<span class="text-gray-400 truncate" :title="getImmeubleName(item)">
|
||||
{{ getImmeubleName(item) }}
|
||||
</span>
|
||||
<span class="text-gray-300 font-medium ml-auto">
|
||||
{{ formatCompact(item.total_revenus) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Doughnut } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
ArcElement,
|
||||
Tooltip,
|
||||
Legend
|
||||
} from 'chart.js'
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip, Legend)
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const colors = [
|
||||
'rgba(74, 222, 128, 0.8)', // green
|
||||
'rgba(96, 165, 250, 0.8)', // blue
|
||||
'rgba(251, 191, 36, 0.8)', // amber
|
||||
'rgba(167, 139, 250, 0.8)', // purple
|
||||
'rgba(236, 72, 153, 0.8)', // pink
|
||||
'rgba(34, 211, 238, 0.8)', // cyan
|
||||
'rgba(248, 113, 113, 0.8)', // red
|
||||
'rgba(163, 230, 53, 0.8)', // lime
|
||||
]
|
||||
|
||||
function getColor(index) {
|
||||
return colors[index % colors.length]
|
||||
}
|
||||
|
||||
function getImmeubleName(item) {
|
||||
if (item.adresse) {
|
||||
return item.adresse
|
||||
}
|
||||
return item.immeuble_code
|
||||
}
|
||||
|
||||
const chartData = computed(() => {
|
||||
const sortedData = [...props.data].sort((a, b) => b.total_revenus - a.total_revenus)
|
||||
|
||||
return {
|
||||
labels: sortedData.map(d => getImmeubleName(d)),
|
||||
datasets: [{
|
||||
data: sortedData.map(d => d.total_revenus),
|
||||
backgroundColor: sortedData.map((_, i) => getColor(i)),
|
||||
borderWidth: 0,
|
||||
hoverOffset: 8
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '60%',
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgb(31, 41, 55)',
|
||||
borderColor: 'rgb(75, 85, 99)',
|
||||
borderWidth: 1,
|
||||
titleColor: 'rgb(255, 255, 255)',
|
||||
bodyColor: 'rgb(156, 163, 175)',
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const value = formatCurrency(context.raw)
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0)
|
||||
const percentage = ((context.raw / total) * 100).toFixed(1)
|
||||
return `${value} (${percentage}%)`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)
|
||||
}
|
||||
|
||||
function formatCompact(value) {
|
||||
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`
|
||||
if (value >= 1000) return `${(value / 1000).toFixed(0)}k`
|
||||
return value.toFixed(0)
|
||||
}
|
||||
</script>
|
||||
145
frontend/src/components/revenus/RevenusKpiCards.vue
Normal file
145
frontend/src/components/revenus/RevenusKpiCards.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<!-- Total Revenus -->
|
||||
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-gray-400 uppercase tracking-wide">Total Revenus</span>
|
||||
<svg class="w-4 h-4 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-green-400">
|
||||
{{ formatAmount(kpis.total_revenus) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
Loyers: {{ formatAmount(kpis.total_loyers) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Regles -->
|
||||
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-gray-400 uppercase tracking-wide">Regles</span>
|
||||
<svg class="w-4 h-4 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-blue-400">
|
||||
{{ formatAmount(kpis.total_regles) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
Provisions: {{ formatAmount(kpis.total_provisions) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Impayes -->
|
||||
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-gray-400 uppercase tracking-wide">Impayes</span>
|
||||
<div
|
||||
v-if="kpis.total_impayes > 0"
|
||||
class="flex items-center text-xs font-medium px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400"
|
||||
>
|
||||
<svg class="w-3 h-3 mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
Alerte
|
||||
</div>
|
||||
</div>
|
||||
<div :class="['text-2xl font-bold', kpis.total_impayes > 0 ? 'text-amber-400' : 'text-gray-400']">
|
||||
{{ formatAmount(kpis.total_impayes) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
Taxes: {{ formatAmount(kpis.total_taxes) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Taux Recouvrement -->
|
||||
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-gray-400 uppercase tracking-wide">Recouvrement</span>
|
||||
<svg class="w-4 h-4 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div :class="['text-2xl font-bold', getTauxColor(kpis.taux_recouvrement)]">
|
||||
{{ kpis.taux_recouvrement }}%
|
||||
</div>
|
||||
<div class="w-full bg-gray-700 rounded-full h-1.5 mt-2">
|
||||
<div
|
||||
:class="['h-1.5 rounded-full', getTauxBgColor(kpis.taux_recouvrement)]"
|
||||
:style="{ width: `${Math.min(kpis.taux_recouvrement, 100)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Locataires Actifs -->
|
||||
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-gray-400 uppercase tracking-wide">Locataires</span>
|
||||
<svg class="w-4 h-4 text-cyan-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-cyan-400">
|
||||
{{ kpis.nb_locataires_actifs }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
actifs
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lots Occupes -->
|
||||
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-gray-400 uppercase tracking-wide">Lots</span>
|
||||
<svg class="w-4 h-4 text-pink-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-pink-400">
|
||||
{{ kpis.nb_lots_occupes }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
occupes
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
kpis: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({
|
||||
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
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function formatAmount(amount) {
|
||||
if (amount == null) return '-'
|
||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(amount)
|
||||
}
|
||||
|
||||
function getTauxColor(taux) {
|
||||
if (taux >= 95) return 'text-green-400'
|
||||
if (taux >= 80) return 'text-yellow-400'
|
||||
return 'text-red-400'
|
||||
}
|
||||
|
||||
function getTauxBgColor(taux) {
|
||||
if (taux >= 95) return 'bg-green-400'
|
||||
if (taux >= 80) return 'bg-yellow-400'
|
||||
return 'bg-red-400'
|
||||
}
|
||||
</script>
|
||||
149
frontend/src/components/revenus/RevenusMonthlyChart.vue
Normal file
149
frontend/src/components/revenus/RevenusMonthlyChart.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-medium text-white">Evolution mensuelle des revenus</h3>
|
||||
<div class="flex items-center gap-4 text-xs">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-3 h-3 rounded bg-green-400"></span>
|
||||
<span class="text-gray-400">Loyers</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-3 h-3 rounded bg-blue-400"></span>
|
||||
<span class="text-gray-400">Regles</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-3 h-3 rounded bg-amber-400"></span>
|
||||
<span class="text-gray-400">Impayes</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.length === 0" class="h-64 flex items-center justify-center text-gray-500">
|
||||
Aucune donnee disponible
|
||||
</div>
|
||||
|
||||
<div v-else class="h-64">
|
||||
<Bar :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Bar } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
} from 'chart.js'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const chartData = computed(() => {
|
||||
const labels = props.data.map(d => formatMonth(d.month))
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Loyers',
|
||||
data: props.data.map(d => d.loyers),
|
||||
backgroundColor: 'rgba(74, 222, 128, 0.8)',
|
||||
borderRadius: 4,
|
||||
barPercentage: 0.7,
|
||||
categoryPercentage: 0.8
|
||||
},
|
||||
{
|
||||
label: 'Regles',
|
||||
data: props.data.map(d => d.regles),
|
||||
backgroundColor: 'rgba(96, 165, 250, 0.8)',
|
||||
borderRadius: 4,
|
||||
barPercentage: 0.7,
|
||||
categoryPercentage: 0.8
|
||||
},
|
||||
{
|
||||
label: 'Impayes',
|
||||
data: props.data.map(d => d.impayes),
|
||||
backgroundColor: 'rgba(251, 191, 36, 0.8)',
|
||||
borderRadius: 4,
|
||||
barPercentage: 0.7,
|
||||
categoryPercentage: 0.8
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgb(31, 41, 55)',
|
||||
borderColor: 'rgb(75, 85, 99)',
|
||||
borderWidth: 1,
|
||||
titleColor: 'rgb(255, 255, 255)',
|
||||
bodyColor: 'rgb(156, 163, 175)',
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return `${context.dataset.label}: ${formatCurrency(context.raw)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
font: { size: 11 }
|
||||
}
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: 'rgb(55, 65, 81)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
font: { size: 11 },
|
||||
callback: function(value) {
|
||||
return formatCompact(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatMonth(monthStr) {
|
||||
if (!monthStr) return ''
|
||||
const [year, month] = monthStr.split('-')
|
||||
const months = ['Jan', 'Fev', 'Mar', 'Avr', 'Mai', 'Jun', 'Jul', 'Aou', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
return `${months[parseInt(month) - 1]} ${year.slice(2)}`
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)
|
||||
}
|
||||
|
||||
function formatCompact(value) {
|
||||
if (value >= 1000) return `${(value / 1000).toFixed(0)}k`
|
||||
return value.toString()
|
||||
}
|
||||
</script>
|
||||
218
frontend/src/components/revenus/RevenusTable.vue
Normal file
218
frontend/src/components/revenus/RevenusTable.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<div class="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-sm font-medium text-white">Detail des revenus</h3>
|
||||
<span class="text-xs text-gray-500">({{ revenus.length }} lignes)</span>
|
||||
</div>
|
||||
<button
|
||||
@click="exportCsv"
|
||||
class="text-xs text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
Exporter CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="isLoading" class="p-8 text-center text-gray-500">
|
||||
<svg class="animate-spin h-6 w-6 mx-auto mb-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Chargement...
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div v-else-if="revenus.length > 0" class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-800/50 text-gray-400 text-xs uppercase">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Date</th>
|
||||
<th class="px-4 py-3 text-left">Locataire</th>
|
||||
<th class="px-4 py-3 text-left">Lot</th>
|
||||
<th class="px-4 py-3 text-left">Type</th>
|
||||
<th class="px-4 py-3 text-right">Loyers</th>
|
||||
<th class="px-4 py-3 text-right">Taxes</th>
|
||||
<th class="px-4 py-3 text-right">Total</th>
|
||||
<th class="px-4 py-3 text-right">Regle</th>
|
||||
<th class="px-4 py-3 text-right">Impaye</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-800">
|
||||
<tr
|
||||
v-for="revenu in paginatedRevenus"
|
||||
:key="revenu.id"
|
||||
class="hover:bg-gray-800/30 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3 text-gray-300 whitespace-nowrap">
|
||||
{{ formatDate(revenu.document_date) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-white font-medium max-w-[200px] truncate" :title="revenu.locataire_nom">
|
||||
{{ revenu.locataire_nom }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-400 whitespace-nowrap" :title="getImmeubleName(revenu)">
|
||||
{{ getImmeubleName(revenu) }} / {{ revenu.lot_numero }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span :class="getTypeBadgeClass(revenu.type_ligne)">
|
||||
{{ revenu.type_ligne }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-gray-300 whitespace-nowrap">
|
||||
{{ formatAmount(revenu.loyers) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-gray-400 whitespace-nowrap">
|
||||
{{ formatAmount(revenu.taxes) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-green-400 font-medium whitespace-nowrap">
|
||||
{{ formatAmount(revenu.total) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-blue-400 whitespace-nowrap">
|
||||
{{ formatAmount(revenu.regles) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right whitespace-nowrap">
|
||||
<span :class="revenu.impayes > 0 ? 'text-amber-400 font-medium' : 'text-gray-500'">
|
||||
{{ formatAmount(revenu.impayes) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else class="p-8 text-center text-gray-500">
|
||||
Aucun revenu trouve
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="revenus.length > pageSize" class="p-4 border-t border-gray-700 flex items-center justify-between">
|
||||
<div class="text-xs text-gray-500">
|
||||
Affichage {{ startIndex + 1 }} - {{ endIndex }} sur {{ revenus.length }}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
@click="currentPage = 1"
|
||||
:disabled="currentPage === 1"
|
||||
class="px-2 py-1 text-xs rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<button
|
||||
@click="currentPage--"
|
||||
:disabled="currentPage === 1"
|
||||
class="px-2 py-1 text-xs rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span class="px-3 py-1 text-xs text-gray-400">
|
||||
Page {{ currentPage }} / {{ totalPages }}
|
||||
</span>
|
||||
<button
|
||||
@click="currentPage++"
|
||||
:disabled="currentPage === totalPages"
|
||||
class="px-2 py-1 text-xs rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<button
|
||||
@click="currentPage = totalPages"
|
||||
:disabled="currentPage === totalPages"
|
||||
class="px-2 py-1 text-xs rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
revenus: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const pageSize = 20
|
||||
const currentPage = ref(1)
|
||||
|
||||
const totalPages = computed(() => Math.ceil(props.revenus.length / pageSize))
|
||||
const startIndex = computed(() => (currentPage.value - 1) * pageSize)
|
||||
const endIndex = computed(() => Math.min(startIndex.value + pageSize, props.revenus.length))
|
||||
const paginatedRevenus = computed(() => props.revenus.slice(startIndex.value, endIndex.value))
|
||||
|
||||
function formatAmount(amount) {
|
||||
if (amount == null) return '-'
|
||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(amount)
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
const [year, month, day] = dateStr.split('-')
|
||||
return `${day}/${month}/${year}`
|
||||
}
|
||||
|
||||
function getTypeBadgeClass(type) {
|
||||
const baseClass = 'text-xs px-2 py-0.5 rounded'
|
||||
switch (type) {
|
||||
case 'loyer':
|
||||
return `${baseClass} bg-green-500/20 text-green-400`
|
||||
case 'solde_anterieur':
|
||||
return `${baseClass} bg-amber-500/20 text-amber-400`
|
||||
case 'rappel_loyer':
|
||||
return `${baseClass} bg-blue-500/20 text-blue-400`
|
||||
case 'divers':
|
||||
return `${baseClass} bg-purple-500/20 text-purple-400`
|
||||
default:
|
||||
return `${baseClass} bg-gray-500/20 text-gray-400`
|
||||
}
|
||||
}
|
||||
|
||||
function getImmeubleName(revenu) {
|
||||
if (revenu.immeuble_adresse) {
|
||||
return revenu.immeuble_adresse
|
||||
}
|
||||
return revenu.immeuble_code
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
const headers = ['Date', 'Locataire', 'Immeuble', 'Lot', 'Type', 'Loyers', 'Taxes', 'Provisions', 'Total', 'Regle', 'Impaye']
|
||||
const rows = props.revenus.map(r => [
|
||||
r.document_date,
|
||||
r.locataire_nom,
|
||||
r.immeuble_code,
|
||||
r.lot_numero,
|
||||
r.type_ligne,
|
||||
r.loyers,
|
||||
r.taxes,
|
||||
r.provisions,
|
||||
r.total,
|
||||
r.regles,
|
||||
r.impayes
|
||||
])
|
||||
|
||||
const csvContent = [
|
||||
headers.join(';'),
|
||||
...rows.map(row => row.map(cell => `"${cell ?? ''}"`).join(';'))
|
||||
].join('\n')
|
||||
|
||||
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `revenus_${new Date().toISOString().split('T')[0]}.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
</script>
|
||||
78
frontend/src/components/revenus/TopImpayesList.vue
Normal file
78
frontend/src/components/revenus/TopImpayesList.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-sm font-medium text-white">Locataires avec impayes</h3>
|
||||
<span
|
||||
v-if="data.length > 0"
|
||||
class="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400"
|
||||
>
|
||||
{{ data.length }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.length === 0" class="py-8 text-center text-gray-500">
|
||||
Aucun impaye
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="item in data"
|
||||
:key="item.locataire_id"
|
||||
class="flex items-center justify-between p-3 rounded-lg bg-gray-800/50 hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-white truncate">
|
||||
{{ item.locataire_nom }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ getImmeubleName(item) }} / {{ item.lot_numero }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-1 text-xs text-gray-400">
|
||||
<span>Total: {{ formatAmount(item.total_revenus) }}</span>
|
||||
<span>Regle: {{ formatAmount(item.total_regles) }}</span>
|
||||
<span>{{ item.nb_mois }} mois</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right ml-4">
|
||||
<div class="text-lg font-bold text-amber-400">
|
||||
{{ formatAmount(item.total_impayes) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ getTauxImpaye(item) }}% impaye
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
function formatAmount(amount) {
|
||||
if (amount == null) return '-'
|
||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(amount)
|
||||
}
|
||||
|
||||
function getImmeubleName(item) {
|
||||
if (item.immeuble_adresse) {
|
||||
return item.immeuble_adresse
|
||||
}
|
||||
return item.immeuble_code
|
||||
}
|
||||
|
||||
function getTauxImpaye(item) {
|
||||
if (!item.total_revenus || item.total_revenus === 0) return 0
|
||||
return ((item.total_impayes / item.total_revenus) * 100).toFixed(1)
|
||||
}
|
||||
</script>
|
||||
173
frontend/src/pages/RevenusPage.vue
Normal file
173
frontend/src/pages/RevenusPage.vue
Normal 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>
|
||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomePage from './pages/HomePage.vue'
|
||||
import ExtractPage from './pages/ExtractPage.vue'
|
||||
import AnalyticsPage from './pages/AnalyticsPage.vue'
|
||||
import RevenusPage from './pages/RevenusPage.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -18,6 +19,11 @@ const routes = [
|
||||
path: '/analytics',
|
||||
name: 'analytics',
|
||||
component: AnalyticsPage
|
||||
},
|
||||
{
|
||||
path: '/revenus',
|
||||
name: 'revenus',
|
||||
component: RevenusPage
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from .routes import (
|
||||
tags_router,
|
||||
analytics_router,
|
||||
dashboard_router,
|
||||
revenus_router,
|
||||
)
|
||||
|
||||
app = FastAPI(
|
||||
@@ -39,6 +40,7 @@ app.include_router(documents_router)
|
||||
app.include_router(tags_router)
|
||||
app.include_router(analytics_router)
|
||||
app.include_router(dashboard_router)
|
||||
app.include_router(revenus_router)
|
||||
|
||||
|
||||
# Health check endpoints (keep in main app)
|
||||
|
||||
@@ -5,6 +5,7 @@ from .documents import router as documents_router
|
||||
from .tags import router as tags_router
|
||||
from .analytics import router as analytics_router
|
||||
from .dashboard import router as dashboard_router
|
||||
from .revenus import router as revenus_router
|
||||
|
||||
__all__ = [
|
||||
"extraction_router",
|
||||
@@ -12,4 +13,5 @@ __all__ = [
|
||||
"tags_router",
|
||||
"analytics_router",
|
||||
"dashboard_router",
|
||||
"revenus_router",
|
||||
]
|
||||
|
||||
512
src/plesna_gerance/api/routes/revenus.py
Normal file
512
src/plesna_gerance/api/routes/revenus.py
Normal file
@@ -0,0 +1,512 @@
|
||||
"""Revenus routes - Dedicated endpoints for rental income analytics."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select, func, desc, and_
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ...database import get_session
|
||||
from ...database.models import (
|
||||
Document,
|
||||
Immeuble,
|
||||
Lot,
|
||||
Locataire,
|
||||
Revenu,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/revenus", tags=["revenus"])
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Response models
|
||||
# ============================================================
|
||||
|
||||
|
||||
class RevenuKpiResponse(BaseModel):
|
||||
"""KPIs globaux des revenus locatifs."""
|
||||
|
||||
total_revenus: float
|
||||
total_loyers: float
|
||||
total_taxes: float
|
||||
total_provisions: float
|
||||
total_regles: float
|
||||
total_impayes: float
|
||||
taux_recouvrement: float # Pourcentage regles/total
|
||||
nb_locataires_actifs: int
|
||||
nb_lots_occupes: int
|
||||
|
||||
|
||||
class RevenuMonthlyPoint(BaseModel):
|
||||
"""Point mensuel pour graphiques."""
|
||||
|
||||
month: str # "2024-01"
|
||||
loyers: float
|
||||
taxes: float
|
||||
provisions: float
|
||||
total: float
|
||||
regles: float
|
||||
impayes: float
|
||||
|
||||
|
||||
class RevenuByImmeuble(BaseModel):
|
||||
"""Revenus agreges par immeuble."""
|
||||
|
||||
immeuble_id: int
|
||||
immeuble_code: str
|
||||
adresse: str | None
|
||||
ville: str | None
|
||||
nb_lots: int
|
||||
nb_locataires: int
|
||||
total_revenus: float
|
||||
total_regles: float
|
||||
total_impayes: float
|
||||
taux_recouvrement: float
|
||||
|
||||
|
||||
class RevenuByLot(BaseModel):
|
||||
"""Revenus agreges par lot."""
|
||||
|
||||
lot_id: int
|
||||
lot_numero: str
|
||||
lot_type: str | None
|
||||
immeuble_code: str
|
||||
locataire_nom: str | None
|
||||
total_revenus: float
|
||||
total_regles: float
|
||||
total_impayes: float
|
||||
derniere_date: str | None
|
||||
|
||||
|
||||
class RevenuByLocataire(BaseModel):
|
||||
"""Revenus agreges par locataire."""
|
||||
|
||||
locataire_id: int
|
||||
locataire_nom: str
|
||||
lot_numero: str
|
||||
immeuble_code: str
|
||||
immeuble_adresse: str | None
|
||||
date_debut: str | None
|
||||
total_revenus: float
|
||||
total_regles: float
|
||||
total_impayes: float
|
||||
nb_mois: int
|
||||
|
||||
|
||||
class RevenuDetailResponse(BaseModel):
|
||||
"""Detail d'un revenu."""
|
||||
|
||||
id: int
|
||||
document_date: str
|
||||
document_reference: str | None
|
||||
immeuble_code: str
|
||||
immeuble_adresse: str | None
|
||||
lot_numero: str
|
||||
locataire_nom: str
|
||||
type_ligne: str
|
||||
periode_debut: str | None
|
||||
periode_fin: str | None
|
||||
loyers: float
|
||||
taxes: float
|
||||
provisions: float
|
||||
divers_montant: float
|
||||
divers_libelle: str | None
|
||||
total: float
|
||||
regles: float
|
||||
impayes: float
|
||||
|
||||
|
||||
class RevenusSummaryResponse(BaseModel):
|
||||
"""Resume complet des revenus pour le dashboard."""
|
||||
|
||||
kpis: RevenuKpiResponse
|
||||
by_month: list[RevenuMonthlyPoint]
|
||||
by_immeuble: list[RevenuByImmeuble]
|
||||
top_impayes: list[RevenuByLocataire]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Endpoints
|
||||
# ============================================================
|
||||
|
||||
|
||||
@router.get("/summary", response_model=RevenusSummaryResponse)
|
||||
async def get_revenus_summary(
|
||||
months: int = Query(12, description="Nombre de mois d'historique"),
|
||||
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
|
||||
session: Session = Depends(get_session),
|
||||
) -> RevenusSummaryResponse:
|
||||
"""Retourne un resume complet des revenus locatifs pour le dashboard.
|
||||
|
||||
Inclut les KPIs, l'evolution mensuelle, la repartition par immeuble
|
||||
et les locataires avec le plus d'impayes.
|
||||
"""
|
||||
# Base filters
|
||||
filters = []
|
||||
if immeuble_id:
|
||||
filters.append(Lot.immeuble_id == immeuble_id)
|
||||
|
||||
# Calculate date range
|
||||
today = date.today()
|
||||
start_date = (today.replace(day=1) - timedelta(days=months * 31)).replace(day=1)
|
||||
|
||||
# ========== KPIs ==========
|
||||
kpi_stmt = select(
|
||||
func.sum(Revenu.total).label("total_revenus"),
|
||||
func.sum(Revenu.loyers).label("total_loyers"),
|
||||
func.sum(Revenu.taxes).label("total_taxes"),
|
||||
func.sum(Revenu.provisions).label("total_provisions"),
|
||||
func.sum(Revenu.regles).label("total_regles"),
|
||||
func.sum(Revenu.impayes).label("total_impayes"),
|
||||
).join(Lot, Revenu.lot_id == Lot.id)
|
||||
|
||||
if filters:
|
||||
kpi_stmt = kpi_stmt.where(and_(*filters))
|
||||
|
||||
kpi_result = session.execute(kpi_stmt).first()
|
||||
|
||||
total_revenus = kpi_result.total_revenus or 0.0
|
||||
total_regles = kpi_result.total_regles or 0.0
|
||||
taux_recouvrement = (
|
||||
(total_regles / total_revenus * 100) if total_revenus > 0 else 100.0
|
||||
)
|
||||
|
||||
# Count active locataires and occupied lots
|
||||
locataires_stmt = (
|
||||
select(func.count(func.distinct(Locataire.id)))
|
||||
.join(Lot, Locataire.lot_id == Lot.id)
|
||||
.where(Locataire.date_fin.is_(None))
|
||||
)
|
||||
if immeuble_id:
|
||||
locataires_stmt = locataires_stmt.where(Lot.immeuble_id == immeuble_id)
|
||||
nb_locataires = session.execute(locataires_stmt).scalar() or 0
|
||||
|
||||
lots_stmt = (
|
||||
select(func.count(func.distinct(Revenu.lot_id)))
|
||||
.join(Lot, Revenu.lot_id == Lot.id)
|
||||
.join(Document, Revenu.document_id == Document.id)
|
||||
.where(Document.date >= start_date)
|
||||
)
|
||||
if immeuble_id:
|
||||
lots_stmt = lots_stmt.where(Lot.immeuble_id == immeuble_id)
|
||||
nb_lots = session.execute(lots_stmt).scalar() or 0
|
||||
|
||||
kpis = RevenuKpiResponse(
|
||||
total_revenus=total_revenus,
|
||||
total_loyers=kpi_result.total_loyers or 0.0,
|
||||
total_taxes=kpi_result.total_taxes or 0.0,
|
||||
total_provisions=kpi_result.total_provisions or 0.0,
|
||||
total_regles=total_regles,
|
||||
total_impayes=kpi_result.total_impayes or 0.0,
|
||||
taux_recouvrement=round(taux_recouvrement, 1),
|
||||
nb_locataires_actifs=nb_locataires,
|
||||
nb_lots_occupes=nb_lots,
|
||||
)
|
||||
|
||||
# ========== Monthly evolution ==========
|
||||
monthly_stmt = (
|
||||
select(
|
||||
func.strftime("%Y-%m", Document.date).label("month"),
|
||||
func.sum(Revenu.loyers).label("loyers"),
|
||||
func.sum(Revenu.taxes).label("taxes"),
|
||||
func.sum(Revenu.provisions).label("provisions"),
|
||||
func.sum(Revenu.total).label("total"),
|
||||
func.sum(Revenu.regles).label("regles"),
|
||||
func.sum(Revenu.impayes).label("impayes"),
|
||||
)
|
||||
.join(Document, Revenu.document_id == Document.id)
|
||||
.join(Lot, Revenu.lot_id == Lot.id)
|
||||
.where(Document.date >= start_date)
|
||||
.group_by(func.strftime("%Y-%m", Document.date))
|
||||
.order_by(func.strftime("%Y-%m", Document.date))
|
||||
)
|
||||
|
||||
if immeuble_id:
|
||||
monthly_stmt = monthly_stmt.where(Lot.immeuble_id == immeuble_id)
|
||||
|
||||
monthly_data = []
|
||||
for row in session.execute(monthly_stmt):
|
||||
monthly_data.append(
|
||||
RevenuMonthlyPoint(
|
||||
month=row.month,
|
||||
loyers=row.loyers or 0.0,
|
||||
taxes=row.taxes or 0.0,
|
||||
provisions=row.provisions or 0.0,
|
||||
total=row.total or 0.0,
|
||||
regles=row.regles or 0.0,
|
||||
impayes=row.impayes or 0.0,
|
||||
)
|
||||
)
|
||||
|
||||
# ========== By Immeuble ==========
|
||||
immeuble_stmt = (
|
||||
select(
|
||||
Immeuble.id,
|
||||
Immeuble.code,
|
||||
Immeuble.adresse,
|
||||
Immeuble.ville,
|
||||
func.count(func.distinct(Lot.id)).label("nb_lots"),
|
||||
func.count(func.distinct(Locataire.id)).label("nb_locataires"),
|
||||
func.sum(Revenu.total).label("total_revenus"),
|
||||
func.sum(Revenu.regles).label("total_regles"),
|
||||
func.sum(Revenu.impayes).label("total_impayes"),
|
||||
)
|
||||
.join(Lot, Lot.immeuble_id == Immeuble.id)
|
||||
.join(Revenu, Revenu.lot_id == Lot.id)
|
||||
.outerjoin(Locataire, Locataire.lot_id == Lot.id)
|
||||
.group_by(Immeuble.id)
|
||||
.order_by(desc("total_revenus"))
|
||||
)
|
||||
|
||||
if immeuble_id:
|
||||
immeuble_stmt = immeuble_stmt.where(Immeuble.id == immeuble_id)
|
||||
|
||||
by_immeuble = []
|
||||
for row in session.execute(immeuble_stmt):
|
||||
rev = row.total_revenus or 0.0
|
||||
reg = row.total_regles or 0.0
|
||||
taux = (reg / rev * 100) if rev > 0 else 100.0
|
||||
by_immeuble.append(
|
||||
RevenuByImmeuble(
|
||||
immeuble_id=row.id,
|
||||
immeuble_code=row.code,
|
||||
adresse=row.adresse,
|
||||
ville=row.ville,
|
||||
nb_lots=row.nb_lots or 0,
|
||||
nb_locataires=row.nb_locataires or 0,
|
||||
total_revenus=rev,
|
||||
total_regles=reg,
|
||||
total_impayes=row.total_impayes or 0.0,
|
||||
taux_recouvrement=round(taux, 1),
|
||||
)
|
||||
)
|
||||
|
||||
# ========== Top impayes by locataire ==========
|
||||
impayes_stmt = (
|
||||
select(
|
||||
Locataire.id,
|
||||
Locataire.nom,
|
||||
Locataire.date_debut,
|
||||
Lot.numero,
|
||||
Immeuble.code,
|
||||
Immeuble.adresse,
|
||||
func.sum(Revenu.total).label("total_revenus"),
|
||||
func.sum(Revenu.regles).label("total_regles"),
|
||||
func.sum(Revenu.impayes).label("total_impayes"),
|
||||
func.count(Revenu.id).label("nb_mois"),
|
||||
)
|
||||
.join(Lot, Revenu.lot_id == Lot.id)
|
||||
.join(Locataire, Revenu.locataire_id == Locataire.id)
|
||||
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
|
||||
.group_by(Locataire.id)
|
||||
.having(func.sum(Revenu.impayes) > 0)
|
||||
.order_by(desc("total_impayes"))
|
||||
.limit(10)
|
||||
)
|
||||
|
||||
if immeuble_id:
|
||||
impayes_stmt = impayes_stmt.where(Lot.immeuble_id == immeuble_id)
|
||||
|
||||
top_impayes = []
|
||||
for row in session.execute(impayes_stmt):
|
||||
top_impayes.append(
|
||||
RevenuByLocataire(
|
||||
locataire_id=row.id,
|
||||
locataire_nom=row.nom,
|
||||
lot_numero=row.numero,
|
||||
immeuble_code=row.code,
|
||||
immeuble_adresse=row.adresse,
|
||||
date_debut=str(row.date_debut) if row.date_debut else None,
|
||||
total_revenus=row.total_revenus or 0.0,
|
||||
total_regles=row.total_regles or 0.0,
|
||||
total_impayes=row.total_impayes or 0.0,
|
||||
nb_mois=row.nb_mois or 0,
|
||||
)
|
||||
)
|
||||
|
||||
return RevenusSummaryResponse(
|
||||
kpis=kpis,
|
||||
by_month=monthly_data,
|
||||
by_immeuble=by_immeuble,
|
||||
top_impayes=top_impayes,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/by-lot", response_model=list[RevenuByLot])
|
||||
async def get_revenus_by_lot(
|
||||
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
|
||||
limit: int = Query(50, description="Limite de resultats"),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[RevenuByLot]:
|
||||
"""Retourne les revenus agreges par lot."""
|
||||
stmt = (
|
||||
select(
|
||||
Lot.id,
|
||||
Lot.numero,
|
||||
Lot.type,
|
||||
Immeuble.code,
|
||||
func.max(Locataire.nom).label("locataire_nom"),
|
||||
func.sum(Revenu.total).label("total_revenus"),
|
||||
func.sum(Revenu.regles).label("total_regles"),
|
||||
func.sum(Revenu.impayes).label("total_impayes"),
|
||||
func.max(Document.date).label("derniere_date"),
|
||||
)
|
||||
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
|
||||
.join(Revenu, Revenu.lot_id == Lot.id)
|
||||
.join(Document, Revenu.document_id == Document.id)
|
||||
.outerjoin(
|
||||
Locataire,
|
||||
and_(Locataire.lot_id == Lot.id, Locataire.date_fin.is_(None)),
|
||||
)
|
||||
.group_by(Lot.id)
|
||||
.order_by(desc("total_impayes"), desc("total_revenus"))
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
if immeuble_id:
|
||||
stmt = stmt.where(Lot.immeuble_id == immeuble_id)
|
||||
|
||||
results = []
|
||||
for row in session.execute(stmt):
|
||||
results.append(
|
||||
RevenuByLot(
|
||||
lot_id=row.id,
|
||||
lot_numero=row.numero,
|
||||
lot_type=row.type,
|
||||
immeuble_code=row.code,
|
||||
locataire_nom=row.locataire_nom,
|
||||
total_revenus=row.total_revenus or 0.0,
|
||||
total_regles=row.total_regles or 0.0,
|
||||
total_impayes=row.total_impayes or 0.0,
|
||||
derniere_date=str(row.derniere_date) if row.derniere_date else None,
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/details", response_model=list[RevenuDetailResponse])
|
||||
async def get_revenus_details(
|
||||
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
|
||||
lot_id: int | None = Query(None, description="Filtrer par lot"),
|
||||
locataire_id: int | None = Query(None, description="Filtrer par locataire"),
|
||||
type_ligne: str | None = Query(None, description="Filtrer par type de ligne"),
|
||||
date_debut: date | None = Query(None, description="Date de debut"),
|
||||
date_fin: date | None = Query(None, description="Date de fin"),
|
||||
impayes_only: bool = Query(False, description="Uniquement les impayes"),
|
||||
limit: int = Query(100, description="Limite de resultats"),
|
||||
offset: int = Query(0, description="Offset pour pagination"),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[RevenuDetailResponse]:
|
||||
"""Retourne la liste detaillee des revenus avec filtres."""
|
||||
stmt = (
|
||||
select(
|
||||
Revenu,
|
||||
Document.date.label("document_date"),
|
||||
Document.reference.label("document_reference"),
|
||||
Immeuble.code.label("immeuble_code"),
|
||||
Immeuble.adresse.label("immeuble_adresse"),
|
||||
Lot.numero.label("lot_numero"),
|
||||
Locataire.nom.label("locataire_nom"),
|
||||
)
|
||||
.join(Document, Revenu.document_id == Document.id)
|
||||
.join(Lot, Revenu.lot_id == Lot.id)
|
||||
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
|
||||
.join(Locataire, Revenu.locataire_id == Locataire.id)
|
||||
.order_by(desc(Document.date), desc(Revenu.id))
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if immeuble_id:
|
||||
stmt = stmt.where(Lot.immeuble_id == immeuble_id)
|
||||
if lot_id:
|
||||
stmt = stmt.where(Revenu.lot_id == lot_id)
|
||||
if locataire_id:
|
||||
stmt = stmt.where(Revenu.locataire_id == locataire_id)
|
||||
if type_ligne:
|
||||
stmt = stmt.where(Revenu.type_ligne == type_ligne)
|
||||
if date_debut:
|
||||
stmt = stmt.where(Document.date >= date_debut)
|
||||
if date_fin:
|
||||
stmt = stmt.where(Document.date <= date_fin)
|
||||
if impayes_only:
|
||||
stmt = stmt.where(Revenu.impayes > 0)
|
||||
|
||||
stmt = stmt.limit(limit).offset(offset)
|
||||
|
||||
results = []
|
||||
for row in session.execute(stmt):
|
||||
rev = row.Revenu
|
||||
results.append(
|
||||
RevenuDetailResponse(
|
||||
id=rev.id,
|
||||
document_date=str(row.document_date),
|
||||
document_reference=row.document_reference,
|
||||
immeuble_code=row.immeuble_code,
|
||||
immeuble_adresse=row.immeuble_adresse,
|
||||
lot_numero=row.lot_numero,
|
||||
locataire_nom=row.locataire_nom,
|
||||
type_ligne=rev.type_ligne or "",
|
||||
periode_debut=str(rev.periode_debut) if rev.periode_debut else None,
|
||||
periode_fin=str(rev.periode_fin) if rev.periode_fin else None,
|
||||
loyers=rev.loyers or 0.0,
|
||||
taxes=rev.taxes or 0.0,
|
||||
provisions=rev.provisions or 0.0,
|
||||
divers_montant=rev.divers_montant or 0.0,
|
||||
divers_libelle=rev.divers_libelle,
|
||||
total=rev.total or 0.0,
|
||||
regles=rev.regles or 0.0,
|
||||
impayes=rev.impayes or 0.0,
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/immeubles", response_model=list[RevenuByImmeuble])
|
||||
async def get_immeubles_with_revenus(
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[RevenuByImmeuble]:
|
||||
"""Retourne la liste des immeubles avec leurs stats de revenus."""
|
||||
stmt = (
|
||||
select(
|
||||
Immeuble.id,
|
||||
Immeuble.code,
|
||||
Immeuble.adresse,
|
||||
Immeuble.ville,
|
||||
func.count(func.distinct(Lot.id)).label("nb_lots"),
|
||||
func.count(func.distinct(Locataire.id)).label("nb_locataires"),
|
||||
func.sum(Revenu.total).label("total_revenus"),
|
||||
func.sum(Revenu.regles).label("total_regles"),
|
||||
func.sum(Revenu.impayes).label("total_impayes"),
|
||||
)
|
||||
.outerjoin(Lot, Lot.immeuble_id == Immeuble.id)
|
||||
.outerjoin(Revenu, Revenu.lot_id == Lot.id)
|
||||
.outerjoin(Locataire, Locataire.lot_id == Lot.id)
|
||||
.group_by(Immeuble.id)
|
||||
.order_by(Immeuble.code)
|
||||
)
|
||||
|
||||
results = []
|
||||
for row in session.execute(stmt):
|
||||
rev = row.total_revenus or 0.0
|
||||
reg = row.total_regles or 0.0
|
||||
taux = (reg / rev * 100) if rev > 0 else 100.0
|
||||
results.append(
|
||||
RevenuByImmeuble(
|
||||
immeuble_id=row.id,
|
||||
immeuble_code=row.code,
|
||||
adresse=row.adresse,
|
||||
ville=row.ville,
|
||||
nb_lots=row.nb_lots or 0,
|
||||
nb_locataires=row.nb_locataires or 0,
|
||||
total_revenus=rev,
|
||||
total_regles=reg,
|
||||
total_impayes=row.total_impayes or 0.0,
|
||||
taux_recouvrement=round(taux, 1),
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
Reference in New Issue
Block a user