feat: enhance homepage with financial summary and sparklines
- Add dashboard API endpoints for financial summary, recent revenus, monthly trends, and immeuble shortcuts - Create new dashboard components: FinancialSummary with sparklines, QuickActions, RecentRevenus, MiniTrendChart, ImmeubleShortcuts - Refactor HomePage with prominent drag & drop zone and data-driven cards - Financial cards now show last document data with 6-month trend sparklines in background - Remove redundant import button, keep single upload zone at top
This commit is contained in:
181
frontend/src/components/dashboard/FinancialSummary.vue
Normal file
181
frontend/src/components/dashboard/FinancialSummary.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Info dernier document -->
|
||||
<div v-if="data.last_document_date" class="mb-3 text-xs text-gray-500">
|
||||
Dernier document: <span class="text-gray-400">{{ data.last_document_reference }}</span>
|
||||
du <span class="text-gray-400">{{ formatDate(data.last_document_date) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<!-- Revenus -->
|
||||
<div class="relative bg-gray-900 rounded-lg p-4 border border-gray-700 overflow-hidden">
|
||||
<!-- Sparkline background -->
|
||||
<svg
|
||||
v-if="data.revenus_history?.length"
|
||||
class="absolute bottom-0 left-0 right-0 h-12 opacity-20"
|
||||
preserveAspectRatio="none"
|
||||
:viewBox="`0 0 ${data.revenus_history.length * 20} 50`"
|
||||
>
|
||||
<path
|
||||
:d="getSparklinePath(data.revenus_history)"
|
||||
fill="none"
|
||||
stroke="rgb(74, 222, 128)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-gray-400 uppercase tracking-wide">Revenus</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-green-400">
|
||||
{{ formatAmount(data.revenus) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Depenses -->
|
||||
<div class="relative bg-gray-900 rounded-lg p-4 border border-gray-700 overflow-hidden">
|
||||
<!-- Sparkline background -->
|
||||
<svg
|
||||
v-if="data.depenses_history?.length"
|
||||
class="absolute bottom-0 left-0 right-0 h-12 opacity-20"
|
||||
preserveAspectRatio="none"
|
||||
:viewBox="`0 0 ${data.depenses_history.length * 20} 50`"
|
||||
>
|
||||
<path
|
||||
:d="getSparklinePath(data.depenses_history)"
|
||||
fill="none"
|
||||
stroke="rgb(248, 113, 113)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-gray-400 uppercase tracking-wide">Depenses</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-red-400">
|
||||
{{ formatAmount(data.depenses) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Impayes -->
|
||||
<div class="relative bg-gray-900 rounded-lg p-4 border border-gray-700 overflow-hidden">
|
||||
<!-- Sparkline background -->
|
||||
<svg
|
||||
v-if="data.impayes_history?.length"
|
||||
class="absolute bottom-0 left-0 right-0 h-12 opacity-20"
|
||||
preserveAspectRatio="none"
|
||||
:viewBox="`0 0 ${data.impayes_history.length * 20} 50`"
|
||||
>
|
||||
<path
|
||||
:d="getSparklinePath(data.impayes_history)"
|
||||
fill="none"
|
||||
stroke="rgb(251, 191, 36)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-gray-400 uppercase tracking-wide">Impayes</span>
|
||||
<div
|
||||
v-if="data.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>
|
||||
Attention
|
||||
</div>
|
||||
</div>
|
||||
<div :class="['text-2xl font-bold', data.impayes > 0 ? 'text-amber-400' : 'text-gray-400']">
|
||||
{{ formatAmount(data.impayes) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Solde -->
|
||||
<div class="relative bg-gray-900 rounded-lg p-4 border border-gray-700 overflow-hidden">
|
||||
<!-- Sparkline background -->
|
||||
<svg
|
||||
v-if="data.solde_history?.length"
|
||||
class="absolute bottom-0 left-0 right-0 h-12 opacity-20"
|
||||
preserveAspectRatio="none"
|
||||
:viewBox="`0 0 ${data.solde_history.length * 20} 50`"
|
||||
>
|
||||
<path
|
||||
:d="getSparklinePath(data.solde_history)"
|
||||
fill="none"
|
||||
stroke="rgb(96, 165, 250)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-gray-400 uppercase tracking-wide">Solde</span>
|
||||
</div>
|
||||
<div :class="['text-2xl font-bold', data.solde >= 0 ? 'text-blue-400' : 'text-red-400']">
|
||||
{{ formatAmount(data.solde) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({
|
||||
last_document_date: null,
|
||||
last_document_reference: null,
|
||||
revenus: 0,
|
||||
impayes: 0,
|
||||
depenses: 0,
|
||||
solde: 0,
|
||||
revenus_history: [],
|
||||
impayes_history: [],
|
||||
depenses_history: [],
|
||||
solde_history: []
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
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 getSparklinePath(history) {
|
||||
if (!history || history.length === 0) return ''
|
||||
|
||||
const values = history.map(h => h.value)
|
||||
const max = Math.max(...values, 1) // Eviter division par 0
|
||||
const min = Math.min(...values, 0)
|
||||
const range = max - min || 1
|
||||
|
||||
const width = history.length * 20
|
||||
const height = 50
|
||||
const padding = 5
|
||||
|
||||
const points = values.map((v, i) => {
|
||||
const x = (i / (values.length - 1 || 1)) * (width - padding * 2) + padding
|
||||
const y = height - padding - ((v - min) / range) * (height - padding * 2)
|
||||
return `${x},${y}`
|
||||
})
|
||||
|
||||
return `M ${points.join(' L ')}`
|
||||
}
|
||||
</script>
|
||||
93
frontend/src/components/dashboard/ImmeubleShortcuts.vue
Normal file
93
frontend/src/components/dashboard/ImmeubleShortcuts.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="bg-gray-900 rounded-lg overflow-hidden border border-gray-700">
|
||||
<div class="px-4 py-3 border-b border-gray-700 flex items-center justify-between">
|
||||
<h2 class="text-sm font-medium text-gray-300">Vos immeubles</h2>
|
||||
<button
|
||||
@click="$emit('view-all')"
|
||||
class="text-xs text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
Voir tout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="p-8 text-center text-gray-500">
|
||||
<div class="inline-block animate-spin rounded-full h-6 w-6 border-2 border-gray-600 border-t-blue-400"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="immeubles.length === 0" class="p-8 text-center text-gray-500">
|
||||
Aucun immeuble enregistre.
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 p-3">
|
||||
<button
|
||||
v-for="immeuble in immeubles"
|
||||
:key="immeuble.id"
|
||||
@click="$emit('select', immeuble)"
|
||||
class="text-left p-4 bg-gray-800 hover:bg-gray-700 rounded-lg border border-gray-700 hover:border-blue-500 transition-all group"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<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="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>
|
||||
<span class="text-sm font-medium text-white truncate group-hover:text-blue-400 transition-colors">
|
||||
{{ immeuble.code }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-400 truncate">
|
||||
{{ immeuble.adresse || 'Adresse non renseignee' }}
|
||||
</div>
|
||||
<div v-if="immeuble.ville" class="text-xs text-gray-500">
|
||||
{{ immeuble.ville }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid grid-cols-2 gap-2 text-xs">
|
||||
<div class="bg-gray-900/50 rounded px-2 py-1">
|
||||
<div class="text-gray-500">Lots</div>
|
||||
<div class="text-white font-medium">{{ immeuble.nb_lots }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-900/50 rounded px-2 py-1">
|
||||
<div class="text-gray-500">Locataires</div>
|
||||
<div class="text-white font-medium">{{ immeuble.nb_locataires }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex items-center justify-between text-xs">
|
||||
<div>
|
||||
<span class="text-gray-500">Revenus:</span>
|
||||
<span class="text-green-400 font-medium ml-1">{{ formatAmount(immeuble.total_revenus) }}</span>
|
||||
</div>
|
||||
<div v-if="immeuble.total_impayes > 0" class="text-amber-400">
|
||||
{{ formatAmount(immeuble.total_impayes) }} impayes
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
immeubles: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['select', 'view-all'])
|
||||
|
||||
function formatAmount(amount) {
|
||||
if (amount == null || amount === 0) return '-'
|
||||
if (amount >= 1000) {
|
||||
return (amount / 1000).toFixed(1) + 'k'
|
||||
}
|
||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(amount)
|
||||
}
|
||||
</script>
|
||||
146
frontend/src/components/dashboard/MiniTrendChart.vue
Normal file
146
frontend/src/components/dashboard/MiniTrendChart.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="bg-gray-900 rounded-lg overflow-hidden border border-gray-700">
|
||||
<div class="px-4 py-3 border-b border-gray-700 flex items-center justify-between">
|
||||
<h2 class="text-sm font-medium text-gray-300">Evolution mensuelle</h2>
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-green-400"></span>
|
||||
Revenus
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-red-400"></span>
|
||||
Depenses
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="p-8 text-center text-gray-500">
|
||||
<div class="inline-block animate-spin rounded-full h-6 w-6 border-2 border-gray-600 border-t-blue-400"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="trends.length === 0" class="p-8 text-center text-gray-500">
|
||||
Pas assez de donnees.
|
||||
</div>
|
||||
|
||||
<div v-else class="p-4">
|
||||
<div class="h-40">
|
||||
<Bar :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</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({
|
||||
trends: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
function formatMonth(monthStr) {
|
||||
const [year, month] = monthStr.split('-')
|
||||
const months = ['Jan', 'Fev', 'Mar', 'Avr', 'Mai', 'Juin', 'Juil', 'Aout', 'Sept', 'Oct', 'Nov', 'Dec']
|
||||
return `${months[parseInt(month) - 1]} ${year.slice(2)}`
|
||||
}
|
||||
|
||||
const chartData = computed(() => ({
|
||||
labels: props.trends.map(t => formatMonth(t.month)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Revenus',
|
||||
data: props.trends.map(t => t.revenus),
|
||||
backgroundColor: 'rgba(74, 222, 128, 0.7)',
|
||||
borderColor: 'rgb(74, 222, 128)',
|
||||
borderWidth: 1,
|
||||
borderRadius: 4
|
||||
},
|
||||
{
|
||||
label: 'Depenses',
|
||||
data: props.trends.map(t => t.depenses),
|
||||
backgroundColor: 'rgba(248, 113, 113, 0.7)',
|
||||
borderColor: 'rgb(248, 113, 113)',
|
||||
borderWidth: 1,
|
||||
borderRadius: 4
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgb(17, 24, 39)',
|
||||
titleColor: 'rgb(209, 213, 219)',
|
||||
bodyColor: 'rgb(156, 163, 175)',
|
||||
borderColor: 'rgb(55, 65, 81)',
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const value = context.raw
|
||||
return `${context.dataset.label}: ${new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
font: {
|
||||
size: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: 'rgba(55, 65, 81, 0.5)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
font: {
|
||||
size: 10
|
||||
},
|
||||
callback: function(value) {
|
||||
if (value >= 1000) {
|
||||
return (value / 1000).toFixed(0) + 'k'
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
148
frontend/src/components/dashboard/QuickActions.vue
Normal file
148
frontend/src/components/dashboard/QuickActions.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<button
|
||||
v-for="action in actions"
|
||||
:key="action.id"
|
||||
@click="handleAction(action)"
|
||||
class="flex items-center gap-3 p-4 bg-gray-900 hover:bg-gray-800 rounded-lg border border-gray-700 hover:border-blue-500 transition-all group"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'p-2 rounded-lg transition-colors',
|
||||
action.bgClass,
|
||||
action.hoverBgClass
|
||||
]"
|
||||
>
|
||||
<component :is="action.icon" class="w-5 h-5" :class="action.iconClass" />
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-medium text-white group-hover:text-blue-400 transition-colors">
|
||||
{{ action.label }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">{{ action.description }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const emit = defineEmits(['import-pdf'])
|
||||
const router = useRouter()
|
||||
|
||||
// Icones en composants inline
|
||||
const UploadIcon = {
|
||||
render() {
|
||||
return h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' }, [
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
'stroke-width': '2',
|
||||
d: 'M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12'
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
const ChartIcon = {
|
||||
render() {
|
||||
return h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' }, [
|
||||
h('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'
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
const BuildingIcon = {
|
||||
render() {
|
||||
return h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' }, [
|
||||
h('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'
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
const DocumentIcon = {
|
||||
render() {
|
||||
return h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' }, [
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
'stroke-width': '2',
|
||||
d: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
const actions = [
|
||||
{
|
||||
id: 'import',
|
||||
label: 'Importer un PDF',
|
||||
description: 'Nouveau document',
|
||||
icon: UploadIcon,
|
||||
bgClass: 'bg-blue-500/20',
|
||||
hoverBgClass: 'group-hover:bg-blue-500/30',
|
||||
iconClass: 'text-blue-400',
|
||||
action: 'import'
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
label: 'Analyses',
|
||||
description: 'Voir les graphiques',
|
||||
icon: ChartIcon,
|
||||
bgClass: 'bg-purple-500/20',
|
||||
hoverBgClass: 'group-hover:bg-purple-500/30',
|
||||
iconClass: 'text-purple-400',
|
||||
action: 'analytics'
|
||||
},
|
||||
{
|
||||
id: 'immeubles',
|
||||
label: 'Immeubles',
|
||||
description: 'Gerer les biens',
|
||||
icon: BuildingIcon,
|
||||
bgClass: 'bg-emerald-500/20',
|
||||
hoverBgClass: 'group-hover:bg-emerald-500/30',
|
||||
iconClass: 'text-emerald-400',
|
||||
action: 'immeubles'
|
||||
},
|
||||
{
|
||||
id: 'documents',
|
||||
label: 'Documents',
|
||||
description: 'Historique',
|
||||
icon: DocumentIcon,
|
||||
bgClass: 'bg-amber-500/20',
|
||||
hoverBgClass: 'group-hover:bg-amber-500/30',
|
||||
iconClass: 'text-amber-400',
|
||||
action: 'documents'
|
||||
}
|
||||
]
|
||||
|
||||
function handleAction(action) {
|
||||
switch (action.action) {
|
||||
case 'import':
|
||||
emit('import-pdf')
|
||||
break
|
||||
case 'analytics':
|
||||
router.push('/analytics')
|
||||
break
|
||||
case 'immeubles':
|
||||
router.push('/analytics?view=immeubles')
|
||||
break
|
||||
case 'documents':
|
||||
// Scroll to documents section on home page
|
||||
document.getElementById('recent-documents')?.scrollIntoView({ behavior: 'smooth' })
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
100
frontend/src/components/dashboard/RecentRevenus.vue
Normal file
100
frontend/src/components/dashboard/RecentRevenus.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="bg-gray-900 rounded-lg overflow-hidden border border-gray-700">
|
||||
<div class="px-4 py-3 border-b border-gray-700 flex items-center justify-between">
|
||||
<h2 class="text-sm font-medium text-gray-300">Derniers loyers</h2>
|
||||
<span class="text-xs text-gray-500">{{ revenus.length }} entrees</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="p-8 text-center text-gray-500">
|
||||
<div class="inline-block animate-spin rounded-full h-6 w-6 border-2 border-gray-600 border-t-blue-400"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="revenus.length === 0" class="p-8 text-center text-gray-500">
|
||||
Aucun revenu enregistre.
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y divide-gray-800">
|
||||
<div
|
||||
v-for="revenu in revenus"
|
||||
:key="revenu.id"
|
||||
class="px-4 py-3 hover:bg-gray-800/50 transition-colors"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-white truncate">
|
||||
{{ revenu.locataire_nom }}
|
||||
</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-700 text-gray-400">
|
||||
{{ revenu.immeuble_code }} - {{ revenu.lot_numero }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-xs text-gray-500">{{ formatDate(revenu.document_date) }}</span>
|
||||
<span
|
||||
:class="[
|
||||
'text-xs px-1.5 py-0.5 rounded',
|
||||
getTypeBadgeClass(revenu.type_ligne)
|
||||
]"
|
||||
>
|
||||
{{ formatTypeLigne(revenu.type_ligne) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right ml-4">
|
||||
<div class="text-sm font-medium text-green-400">
|
||||
{{ formatAmount(revenu.total) }}
|
||||
</div>
|
||||
<div v-if="revenu.impayes > 0" class="text-xs text-amber-400">
|
||||
Impaye: {{ formatAmount(revenu.impayes) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
revenus: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
const [year, month, day] = dateStr.split('-')
|
||||
return `${day}/${month}/${year}`
|
||||
}
|
||||
|
||||
function formatAmount(amount) {
|
||||
if (amount == null) return '-'
|
||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(amount)
|
||||
}
|
||||
|
||||
function formatTypeLigne(type) {
|
||||
const types = {
|
||||
'loyer': 'Loyer',
|
||||
'solde_anterieur': 'Solde ant.',
|
||||
'rappel_loyer': 'Rappel',
|
||||
'divers': 'Divers'
|
||||
}
|
||||
return types[type] || type
|
||||
}
|
||||
|
||||
function getTypeBadgeClass(type) {
|
||||
const classes = {
|
||||
'loyer': 'bg-blue-500/20 text-blue-400',
|
||||
'solde_anterieur': 'bg-purple-500/20 text-purple-400',
|
||||
'rappel_loyer': 'bg-amber-500/20 text-amber-400',
|
||||
'divers': 'bg-gray-600 text-gray-300'
|
||||
}
|
||||
return classes[type] || 'bg-gray-600 text-gray-300'
|
||||
}
|
||||
</script>
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="flex-1 overflow-auto p-6 bg-gray-800">
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<div class="max-w-6xl mx-auto space-y-6">
|
||||
|
||||
<!-- Upload zone compacte -->
|
||||
<!-- Zone d'upload PDF visible -->
|
||||
<div
|
||||
class="border-2 border-dashed border-gray-600 rounded-lg p-6 text-center hover:border-blue-500 transition-colors cursor-pointer bg-gray-900/50"
|
||||
@click="triggerFileInput"
|
||||
@@ -24,58 +24,93 @@
|
||||
</svg>
|
||||
<p class="text-sm">
|
||||
<span v-if="!isDragging">Glisser un PDF ici ou <span class="text-blue-400">cliquer pour importer</span></span>
|
||||
<span v-else class="text-blue-400 font-medium">Déposer le fichier ici</span>
|
||||
<span v-else class="text-blue-400 font-medium">Deposer le fichier ici</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats rapides -->
|
||||
<div class="grid grid-cols-3 md:grid-cols-6 gap-3">
|
||||
<div v-for="stat in statsDisplay" :key="stat.key" class="bg-gray-900 rounded-lg p-3 text-center">
|
||||
<div class="text-2xl font-bold text-white">{{ stats[stat.key] ?? '-' }}</div>
|
||||
<div class="text-xs text-gray-400">{{ stat.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Actions rapides -->
|
||||
<QuickActions @import-pdf="triggerFileInput" />
|
||||
|
||||
<!-- Derniers documents -->
|
||||
<div class="bg-gray-900 rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-700">
|
||||
<h2 class="text-sm font-medium text-gray-300">Derniers documents importes</h2>
|
||||
</div>
|
||||
<!-- Resume financier -->
|
||||
<FinancialSummary :data="financialSummary" />
|
||||
|
||||
<!-- Grille principale -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
<div v-if="isLoading" class="p-8 text-center text-gray-500">
|
||||
Chargement...
|
||||
<!-- Colonne gauche: Graphique tendance + Revenus recents -->
|
||||
<div class="space-y-6">
|
||||
<MiniTrendChart :trends="monthlyTrends" :loading="isLoadingTrends" />
|
||||
<RecentRevenus :revenus="recentRevenus" :loading="isLoadingRevenus" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="documents.length === 0" class="p-8 text-center text-gray-500">
|
||||
Aucun document importe pour le moment.
|
||||
|
||||
<!-- Colonne droite: Immeubles + Documents -->
|
||||
<div class="space-y-6">
|
||||
<ImmeubleShortcuts
|
||||
:immeubles="immeubleShortcuts"
|
||||
:loading="isLoadingImmeubles"
|
||||
@select="handleImmeubleSelect"
|
||||
@view-all="goToAnalytics"
|
||||
/>
|
||||
|
||||
<!-- Derniers documents -->
|
||||
<div id="recent-documents" class="bg-gray-900 rounded-lg overflow-hidden border border-gray-700">
|
||||
<div class="px-4 py-3 border-b border-gray-700 flex items-center justify-between">
|
||||
<h2 class="text-sm font-medium text-gray-300">Derniers documents importes</h2>
|
||||
<span class="text-xs text-gray-500">{{ documents.length }} documents</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoadingDocs" class="p-8 text-center text-gray-500">
|
||||
<div class="inline-block animate-spin rounded-full h-6 w-6 border-2 border-gray-600 border-t-blue-400"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="documents.length === 0" class="p-8 text-center text-gray-500">
|
||||
<svg class="mx-auto h-12 w-12 mb-3 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p>Aucun document importe.</p>
|
||||
<button
|
||||
@click="triggerFileInput"
|
||||
class="mt-3 text-sm text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Importer votre premier PDF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y divide-gray-800">
|
||||
<div
|
||||
v-for="doc in documents"
|
||||
:key="doc.id"
|
||||
class="px-4 py-3 hover:bg-gray-800/50 transition-colors"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-mono text-white">{{ doc.reference }}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-700 text-gray-400">
|
||||
{{ doc.immeuble_code }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">
|
||||
{{ formatDate(doc.date) }}
|
||||
<span v-if="doc.immeuble_adresse" class="ml-2">{{ doc.immeuble_adresse }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right ml-4">
|
||||
<span
|
||||
:class="[
|
||||
'text-sm font-medium',
|
||||
doc.solde_type === 'crediteur' ? 'text-green-400' : 'text-red-400'
|
||||
]"
|
||||
>
|
||||
{{ formatAmount(doc.solde_montant) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table v-else class="w-full">
|
||||
<thead class="bg-gray-800/50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-400 uppercase">Reference</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-400 uppercase">Date</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-400 uppercase">Immeuble</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-400 uppercase">Solde</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-800">
|
||||
<tr v-for="doc in documents" :key="doc.id" class="hover:bg-gray-800/50">
|
||||
<td class="px-4 py-3 text-sm text-white font-mono">{{ doc.reference }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-300">{{ formatDate(doc.date) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-300">
|
||||
<span class="text-gray-500">{{ doc.immeuble_code }}</span>
|
||||
<span v-if="doc.immeuble_adresse" class="ml-2">{{ doc.immeuble_adresse }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-right">
|
||||
<span :class="doc.solde_type === 'crediteur' ? 'text-green-400' : 'text-red-400'">
|
||||
{{ formatAmount(doc.solde_montant) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -87,21 +122,39 @@ import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { pendingFile } from '../store'
|
||||
|
||||
import QuickActions from '../components/dashboard/QuickActions.vue'
|
||||
import FinancialSummary from '../components/dashboard/FinancialSummary.vue'
|
||||
import RecentRevenus from '../components/dashboard/RecentRevenus.vue'
|
||||
import MiniTrendChart from '../components/dashboard/MiniTrendChart.vue'
|
||||
import ImmeubleShortcuts from '../components/dashboard/ImmeubleShortcuts.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const fileInput = ref(null)
|
||||
const isDragging = ref(false)
|
||||
const isLoading = ref(true)
|
||||
const stats = ref({})
|
||||
const documents = ref([])
|
||||
|
||||
const statsDisplay = [
|
||||
{ key: 'documents', label: 'Documents' },
|
||||
{ key: 'immeubles', label: 'Immeubles' },
|
||||
{ key: 'lots', label: 'Lots' },
|
||||
{ key: 'locataires', label: 'Locataires' },
|
||||
{ key: 'revenus', label: 'Revenus' },
|
||||
{ key: 'depenses', label: 'Depenses' },
|
||||
]
|
||||
// Loading states
|
||||
const isLoadingDocs = ref(true)
|
||||
const isLoadingRevenus = ref(true)
|
||||
const isLoadingTrends = ref(true)
|
||||
const isLoadingImmeubles = ref(true)
|
||||
|
||||
// Data
|
||||
const documents = ref([])
|
||||
const financialSummary = ref({
|
||||
last_document_date: null,
|
||||
last_document_reference: null,
|
||||
revenus: 0,
|
||||
impayes: 0,
|
||||
depenses: 0,
|
||||
solde: 0,
|
||||
revenus_history: [],
|
||||
impayes_history: [],
|
||||
depenses_history: [],
|
||||
solde_history: []
|
||||
})
|
||||
const recentRevenus = ref([])
|
||||
const monthlyTrends = ref([])
|
||||
const immeubleShortcuts = ref([])
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
@@ -115,24 +168,66 @@ function formatAmount(amount) {
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
// Charger les documents
|
||||
isLoadingDocs.value = true
|
||||
try {
|
||||
const [statsRes, docsRes] = await Promise.all([
|
||||
fetch('/api/stats'),
|
||||
fetch('/api/documents?limit=10')
|
||||
])
|
||||
|
||||
if (statsRes.ok) {
|
||||
stats.value = await statsRes.json()
|
||||
}
|
||||
|
||||
const docsRes = await fetch('/api/documents?limit=5')
|
||||
if (docsRes.ok) {
|
||||
documents.value = await docsRes.json()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load dashboard data:', err)
|
||||
console.error('Failed to load documents:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isLoadingDocs.value = false
|
||||
}
|
||||
|
||||
// Charger le resume financier
|
||||
try {
|
||||
const summaryRes = await fetch('/api/dashboard/financial-summary')
|
||||
if (summaryRes.ok) {
|
||||
financialSummary.value = await summaryRes.json()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load financial summary:', err)
|
||||
}
|
||||
|
||||
// Charger les revenus recents
|
||||
isLoadingRevenus.value = true
|
||||
try {
|
||||
const revenusRes = await fetch('/api/dashboard/recent-revenus?limit=5')
|
||||
if (revenusRes.ok) {
|
||||
recentRevenus.value = await revenusRes.json()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load recent revenus:', err)
|
||||
} finally {
|
||||
isLoadingRevenus.value = false
|
||||
}
|
||||
|
||||
// Charger les tendances mensuelles
|
||||
isLoadingTrends.value = true
|
||||
try {
|
||||
const trendsRes = await fetch('/api/dashboard/monthly-trends?months=6')
|
||||
if (trendsRes.ok) {
|
||||
monthlyTrends.value = await trendsRes.json()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load monthly trends:', err)
|
||||
} finally {
|
||||
isLoadingTrends.value = false
|
||||
}
|
||||
|
||||
// Charger les raccourcis immeubles
|
||||
isLoadingImmeubles.value = true
|
||||
try {
|
||||
const immeublesRes = await fetch('/api/dashboard/immeubles-shortcuts?limit=6')
|
||||
if (immeublesRes.ok) {
|
||||
immeubleShortcuts.value = await immeublesRes.json()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load immeubles shortcuts:', err)
|
||||
} finally {
|
||||
isLoadingImmeubles.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +253,14 @@ function onDrop(e) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleImmeubleSelect(immeuble) {
|
||||
router.push(`/analytics?immeuble_id=${immeuble.id}`)
|
||||
}
|
||||
|
||||
function goToAnalytics() {
|
||||
router.push('/analytics')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
@@ -8,7 +8,13 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .. import __version__
|
||||
from ..database import init_db
|
||||
from .routes import extraction_router, documents_router, tags_router, analytics_router
|
||||
from .routes import (
|
||||
extraction_router,
|
||||
documents_router,
|
||||
tags_router,
|
||||
analytics_router,
|
||||
dashboard_router,
|
||||
)
|
||||
|
||||
app = FastAPI(
|
||||
title="Plesna Gérance API",
|
||||
@@ -32,6 +38,7 @@ app.include_router(extraction_router)
|
||||
app.include_router(documents_router)
|
||||
app.include_router(tags_router)
|
||||
app.include_router(analytics_router)
|
||||
app.include_router(dashboard_router)
|
||||
|
||||
|
||||
# Health check endpoints (keep in main app)
|
||||
|
||||
@@ -4,10 +4,12 @@ from .extraction import router as extraction_router
|
||||
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
|
||||
|
||||
__all__ = [
|
||||
"extraction_router",
|
||||
"documents_router",
|
||||
"tags_router",
|
||||
"analytics_router",
|
||||
"dashboard_router",
|
||||
]
|
||||
|
||||
425
src/plesna_gerance/api/routes/dashboard.py
Normal file
425
src/plesna_gerance/api/routes/dashboard.py
Normal file
@@ -0,0 +1,425 @@
|
||||
"""Dashboard routes - Aggregated data for the home page."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select, func, desc
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ...database import get_session
|
||||
from ...database.models import (
|
||||
Document,
|
||||
Immeuble,
|
||||
Lot,
|
||||
Locataire,
|
||||
Revenu,
|
||||
Depense,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Response models
|
||||
# ============================================================
|
||||
|
||||
|
||||
class MonthlyDataPoint(BaseModel):
|
||||
"""Point de donnees mensuel pour sparkline."""
|
||||
|
||||
month: str
|
||||
value: float
|
||||
|
||||
|
||||
class FinancialSummaryResponse(BaseModel):
|
||||
"""Resume financier du dernier document avec historique pour sparklines."""
|
||||
|
||||
# Valeurs du dernier document
|
||||
last_document_date: str | None = None
|
||||
last_document_reference: str | None = None
|
||||
revenus: float
|
||||
impayes: float
|
||||
depenses: float
|
||||
solde: float
|
||||
# Historique pour sparklines (6 derniers mois)
|
||||
revenus_history: list[MonthlyDataPoint] = []
|
||||
impayes_history: list[MonthlyDataPoint] = []
|
||||
depenses_history: list[MonthlyDataPoint] = []
|
||||
solde_history: list[MonthlyDataPoint] = []
|
||||
|
||||
|
||||
class RecentRevenuResponse(BaseModel):
|
||||
"""Revenu recent avec details."""
|
||||
|
||||
id: int
|
||||
document_date: str
|
||||
locataire_nom: str
|
||||
lot_numero: str
|
||||
immeuble_code: str
|
||||
total: float
|
||||
impayes: float
|
||||
type_ligne: str
|
||||
|
||||
|
||||
class MonthlyTrendResponse(BaseModel):
|
||||
"""Tendance mensuelle pour graphique."""
|
||||
|
||||
month: str # "2024-01"
|
||||
revenus: float
|
||||
depenses: float
|
||||
solde: float
|
||||
|
||||
|
||||
class ImmeubleShortcutResponse(BaseModel):
|
||||
"""Raccourci immeuble pour acces rapide."""
|
||||
|
||||
id: int
|
||||
code: str
|
||||
adresse: str | None
|
||||
ville: str | None
|
||||
nb_lots: int
|
||||
nb_locataires: int
|
||||
total_revenus: float
|
||||
total_impayes: float
|
||||
|
||||
|
||||
class DashboardStatsResponse(BaseModel):
|
||||
"""Stats enrichies pour le dashboard."""
|
||||
|
||||
documents: int
|
||||
immeubles: int
|
||||
lots: int
|
||||
locataires: int
|
||||
total_revenus: float
|
||||
total_depenses: float
|
||||
total_impayes: float
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Endpoints
|
||||
# ============================================================
|
||||
|
||||
|
||||
@router.get("/stats", response_model=DashboardStatsResponse)
|
||||
async def get_dashboard_stats(
|
||||
session: Session = Depends(get_session),
|
||||
) -> DashboardStatsResponse:
|
||||
"""Retourne les statistiques enrichies pour le dashboard.
|
||||
|
||||
Inclut les compteurs et les totaux financiers.
|
||||
"""
|
||||
# Compteurs
|
||||
documents_count = session.execute(select(func.count(Document.id))).scalar() or 0
|
||||
immeubles_count = session.execute(select(func.count(Immeuble.id))).scalar() or 0
|
||||
lots_count = session.execute(select(func.count(Lot.id))).scalar() or 0
|
||||
locataires_count = session.execute(select(func.count(Locataire.id))).scalar() or 0
|
||||
|
||||
# Totaux financiers
|
||||
total_revenus = session.execute(select(func.sum(Revenu.total))).scalar() or 0.0
|
||||
total_depenses = session.execute(select(func.sum(Depense.debit))).scalar() or 0.0
|
||||
total_impayes = session.execute(select(func.sum(Revenu.impayes))).scalar() or 0.0
|
||||
|
||||
return DashboardStatsResponse(
|
||||
documents=documents_count,
|
||||
immeubles=immeubles_count,
|
||||
lots=lots_count,
|
||||
locataires=locataires_count,
|
||||
total_revenus=total_revenus,
|
||||
total_depenses=total_depenses,
|
||||
total_impayes=total_impayes,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/financial-summary", response_model=FinancialSummaryResponse)
|
||||
async def get_financial_summary(
|
||||
session: Session = Depends(get_session),
|
||||
) -> FinancialSummaryResponse:
|
||||
"""Retourne le resume financier du dernier document avec historique pour sparklines.
|
||||
|
||||
- Valeurs principales basees sur le dernier document importe
|
||||
- Historique sur 6 mois pour les sparklines
|
||||
"""
|
||||
# Recuperer le dernier document
|
||||
last_doc_stmt = select(Document).order_by(desc(Document.date)).limit(1)
|
||||
last_doc = session.execute(last_doc_stmt).scalar()
|
||||
|
||||
# Valeurs du dernier document
|
||||
revenus = 0.0
|
||||
impayes = 0.0
|
||||
depenses = 0.0
|
||||
last_document_date = None
|
||||
last_document_reference = None
|
||||
|
||||
if last_doc:
|
||||
last_document_date = str(last_doc.date)
|
||||
last_document_reference = last_doc.reference
|
||||
|
||||
# Revenus du dernier document
|
||||
revenus = (
|
||||
session.execute(
|
||||
select(func.sum(Revenu.total)).where(Revenu.document_id == last_doc.id)
|
||||
).scalar()
|
||||
or 0.0
|
||||
)
|
||||
|
||||
# Impayes du dernier document
|
||||
impayes = (
|
||||
session.execute(
|
||||
select(func.sum(Revenu.impayes)).where(
|
||||
Revenu.document_id == last_doc.id
|
||||
)
|
||||
).scalar()
|
||||
or 0.0
|
||||
)
|
||||
|
||||
# Depenses du dernier document
|
||||
depenses = (
|
||||
session.execute(
|
||||
select(func.sum(Depense.debit)).where(
|
||||
Depense.document_id == last_doc.id
|
||||
)
|
||||
).scalar()
|
||||
or 0.0
|
||||
)
|
||||
|
||||
solde = revenus - depenses
|
||||
|
||||
# Historique sur 6 mois pour sparklines
|
||||
today = date.today()
|
||||
start_date = (today.replace(day=1) - timedelta(days=6 * 31)).replace(day=1)
|
||||
|
||||
# Revenus par mois
|
||||
revenus_by_month: dict[str, float] = defaultdict(float)
|
||||
impayes_by_month: dict[str, float] = defaultdict(float)
|
||||
depenses_by_month: dict[str, float] = defaultdict(float)
|
||||
|
||||
# Recuperer revenus et impayes par mois
|
||||
revenus_stmt = (
|
||||
select(
|
||||
Document.date,
|
||||
func.sum(Revenu.total).label("total"),
|
||||
func.sum(Revenu.impayes).label("impayes"),
|
||||
)
|
||||
.join(Revenu, Revenu.document_id == Document.id)
|
||||
.where(Document.date >= start_date)
|
||||
.group_by(Document.date)
|
||||
)
|
||||
for row in session.execute(revenus_stmt):
|
||||
month_key = row.date.strftime("%Y-%m")
|
||||
revenus_by_month[month_key] += row.total or 0.0
|
||||
impayes_by_month[month_key] += row.impayes or 0.0
|
||||
|
||||
# Recuperer depenses par mois
|
||||
depenses_stmt = (
|
||||
select(Document.date, func.sum(Depense.debit).label("total"))
|
||||
.join(Depense, Depense.document_id == Document.id)
|
||||
.where(Document.date >= start_date)
|
||||
.group_by(Document.date)
|
||||
)
|
||||
for row in session.execute(depenses_stmt):
|
||||
month_key = row.date.strftime("%Y-%m")
|
||||
depenses_by_month[month_key] += row.total or 0.0
|
||||
|
||||
# Generer les 6 derniers mois
|
||||
all_months = []
|
||||
current = today.replace(day=1)
|
||||
for _ in range(6):
|
||||
all_months.insert(0, current.strftime("%Y-%m"))
|
||||
current = (current - timedelta(days=1)).replace(day=1)
|
||||
|
||||
# Construire les listes d'historique
|
||||
revenus_history = [
|
||||
MonthlyDataPoint(month=m, value=revenus_by_month.get(m, 0.0))
|
||||
for m in all_months
|
||||
]
|
||||
impayes_history = [
|
||||
MonthlyDataPoint(month=m, value=impayes_by_month.get(m, 0.0))
|
||||
for m in all_months
|
||||
]
|
||||
depenses_history = [
|
||||
MonthlyDataPoint(month=m, value=depenses_by_month.get(m, 0.0))
|
||||
for m in all_months
|
||||
]
|
||||
solde_history = [
|
||||
MonthlyDataPoint(
|
||||
month=m,
|
||||
value=revenus_by_month.get(m, 0.0) - depenses_by_month.get(m, 0.0),
|
||||
)
|
||||
for m in all_months
|
||||
]
|
||||
|
||||
return FinancialSummaryResponse(
|
||||
last_document_date=last_document_date,
|
||||
last_document_reference=last_document_reference,
|
||||
revenus=revenus,
|
||||
impayes=impayes,
|
||||
depenses=depenses,
|
||||
solde=solde,
|
||||
revenus_history=revenus_history,
|
||||
impayes_history=impayes_history,
|
||||
depenses_history=depenses_history,
|
||||
solde_history=solde_history,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/recent-revenus", response_model=list[RecentRevenuResponse])
|
||||
async def get_recent_revenus(
|
||||
limit: int = 10,
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[RecentRevenuResponse]:
|
||||
"""Retourne les derniers revenus/loyers enregistres.
|
||||
|
||||
- **limit**: Nombre maximum de resultats (defaut: 10)
|
||||
"""
|
||||
stmt = (
|
||||
select(
|
||||
Revenu,
|
||||
Document.date.label("document_date"),
|
||||
Locataire.nom.label("locataire_nom"),
|
||||
Lot.numero.label("lot_numero"),
|
||||
Immeuble.code.label("immeuble_code"),
|
||||
)
|
||||
.join(Document, Revenu.document_id == Document.id)
|
||||
.join(Locataire, Revenu.locataire_id == Locataire.id)
|
||||
.join(Lot, Revenu.lot_id == Lot.id)
|
||||
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
|
||||
.order_by(desc(Document.date), desc(Revenu.id))
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
result = session.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
RecentRevenuResponse(
|
||||
id=row.Revenu.id,
|
||||
document_date=str(row.document_date),
|
||||
locataire_nom=row.locataire_nom,
|
||||
lot_numero=row.lot_numero,
|
||||
immeuble_code=row.immeuble_code,
|
||||
total=row.Revenu.total or 0.0,
|
||||
impayes=row.Revenu.impayes or 0.0,
|
||||
type_ligne=row.Revenu.type_ligne or "",
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/monthly-trends", response_model=list[MonthlyTrendResponse])
|
||||
async def get_monthly_trends(
|
||||
months: int = 6,
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[MonthlyTrendResponse]:
|
||||
"""Retourne les tendances mensuelles pour le graphique.
|
||||
|
||||
- **months**: Nombre de mois a inclure (defaut: 6)
|
||||
"""
|
||||
today = date.today()
|
||||
start_date = (today.replace(day=1) - timedelta(days=months * 31)).replace(day=1)
|
||||
|
||||
# Recuperer tous les revenus depuis start_date
|
||||
revenus_stmt = (
|
||||
select(Document.date, func.sum(Revenu.total).label("total"))
|
||||
.join(Revenu, Revenu.document_id == Document.id)
|
||||
.where(Document.date >= start_date)
|
||||
.group_by(Document.date)
|
||||
)
|
||||
|
||||
revenus_result = session.execute(revenus_stmt)
|
||||
revenus_by_month: dict[str, float] = defaultdict(float)
|
||||
for row in revenus_result:
|
||||
month_key = row.date.strftime("%Y-%m")
|
||||
revenus_by_month[month_key] += row.total or 0.0
|
||||
|
||||
# Recuperer toutes les depenses depuis start_date
|
||||
depenses_stmt = (
|
||||
select(Document.date, func.sum(Depense.debit).label("total"))
|
||||
.join(Depense, Depense.document_id == Document.id)
|
||||
.where(Document.date >= start_date)
|
||||
.group_by(Document.date)
|
||||
)
|
||||
|
||||
depenses_result = session.execute(depenses_stmt)
|
||||
depenses_by_month: dict[str, float] = defaultdict(float)
|
||||
for row in depenses_result:
|
||||
month_key = row.date.strftime("%Y-%m")
|
||||
depenses_by_month[month_key] += row.total or 0.0
|
||||
|
||||
# Combiner et trier
|
||||
all_months = sorted(set(revenus_by_month.keys()) | set(depenses_by_month.keys()))
|
||||
|
||||
# Limiter aux derniers mois demandes
|
||||
all_months = all_months[-months:]
|
||||
|
||||
return [
|
||||
MonthlyTrendResponse(
|
||||
month=month,
|
||||
revenus=revenus_by_month.get(month, 0.0),
|
||||
depenses=depenses_by_month.get(month, 0.0),
|
||||
solde=revenus_by_month.get(month, 0.0) - depenses_by_month.get(month, 0.0),
|
||||
)
|
||||
for month in all_months
|
||||
]
|
||||
|
||||
|
||||
@router.get("/immeubles-shortcuts", response_model=list[ImmeubleShortcutResponse])
|
||||
async def get_immeubles_shortcuts(
|
||||
limit: int = 5,
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[ImmeubleShortcutResponse]:
|
||||
"""Retourne les immeubles pour acces rapide avec stats.
|
||||
|
||||
Trie par nombre de documents (plus actifs en premier).
|
||||
|
||||
- **limit**: Nombre maximum d'immeubles (defaut: 5)
|
||||
"""
|
||||
# Requete pour les immeubles avec stats
|
||||
stmt = (
|
||||
select(
|
||||
Immeuble,
|
||||
func.count(func.distinct(Lot.id)).label("nb_lots"),
|
||||
func.count(func.distinct(Locataire.id)).label("nb_locataires"),
|
||||
func.count(func.distinct(Document.id)).label("nb_documents"),
|
||||
)
|
||||
.outerjoin(Lot, Lot.immeuble_id == Immeuble.id)
|
||||
.outerjoin(Locataire, Locataire.lot_id == Lot.id)
|
||||
.outerjoin(Document, Document.immeuble_id == Immeuble.id)
|
||||
.group_by(Immeuble.id)
|
||||
.order_by(desc("nb_documents"))
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
result = session.execute(stmt)
|
||||
immeubles = result.all()
|
||||
|
||||
# Pour chaque immeuble, recuperer les totaux revenus/impayes
|
||||
shortcuts = []
|
||||
for row in immeubles:
|
||||
immeuble = row.Immeuble
|
||||
|
||||
# Revenus de cet immeuble
|
||||
revenus_stmt = (
|
||||
select(func.sum(Revenu.total), func.sum(Revenu.impayes))
|
||||
.join(Lot, Revenu.lot_id == Lot.id)
|
||||
.where(Lot.immeuble_id == immeuble.id)
|
||||
)
|
||||
rev_result = session.execute(revenus_stmt).first()
|
||||
total_revenus = rev_result[0] or 0.0 if rev_result else 0.0
|
||||
total_impayes = rev_result[1] or 0.0 if rev_result else 0.0
|
||||
|
||||
shortcuts.append(
|
||||
ImmeubleShortcutResponse(
|
||||
id=immeuble.id,
|
||||
code=immeuble.code,
|
||||
adresse=immeuble.adresse,
|
||||
ville=immeuble.ville,
|
||||
nb_lots=row.nb_lots or 0,
|
||||
nb_locataires=row.nb_locataires or 0,
|
||||
total_revenus=total_revenus,
|
||||
total_impayes=total_impayes,
|
||||
)
|
||||
)
|
||||
|
||||
return shortcuts
|
||||
Reference in New Issue
Block a user