feat: add rental income dashboard with comprehensive analytics

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

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

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

View File

@@ -0,0 +1,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>