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

@@ -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 -->

View 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>

View 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>

View 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>

View 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>

View 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"
>
&laquo;
</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"
>
&lsaquo;
</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"
>
&rsaquo;
</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"
>
&raquo;
</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>

View 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>

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>

View File

@@ -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
}
]

View File

@@ -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)

View File

@@ -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",
]

View 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