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>
|
<template>
|
||||||
<div class="flex-1 overflow-auto p-6 bg-gray-800">
|
<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
|
<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"
|
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"
|
@click="triggerFileInput"
|
||||||
@@ -24,58 +24,93 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<p class="text-sm">
|
<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-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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats rapides -->
|
<!-- Actions rapides -->
|
||||||
<div class="grid grid-cols-3 md:grid-cols-6 gap-3">
|
<QuickActions @import-pdf="triggerFileInput" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Derniers documents -->
|
<!-- Resume financier -->
|
||||||
<div class="bg-gray-900 rounded-lg overflow-hidden">
|
<FinancialSummary :data="financialSummary" />
|
||||||
<div class="px-4 py-3 border-b border-gray-700">
|
|
||||||
<h2 class="text-sm font-medium text-gray-300">Derniers documents importes</h2>
|
<!-- Grille principale -->
|
||||||
</div>
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
|
||||||
<div v-if="isLoading" class="p-8 text-center text-gray-500">
|
<!-- Colonne gauche: Graphique tendance + Revenus recents -->
|
||||||
Chargement...
|
<div class="space-y-6">
|
||||||
|
<MiniTrendChart :trends="monthlyTrends" :loading="isLoadingTrends" />
|
||||||
|
<RecentRevenus :revenus="recentRevenus" :loading="isLoadingRevenus" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="documents.length === 0" class="p-8 text-center text-gray-500">
|
<!-- Colonne droite: Immeubles + Documents -->
|
||||||
Aucun document importe pour le moment.
|
<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>
|
</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -87,21 +122,39 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { pendingFile } from '../store'
|
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 router = useRouter()
|
||||||
const fileInput = ref(null)
|
const fileInput = ref(null)
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
const isLoading = ref(true)
|
|
||||||
const stats = ref({})
|
|
||||||
const documents = ref([])
|
|
||||||
|
|
||||||
const statsDisplay = [
|
// Loading states
|
||||||
{ key: 'documents', label: 'Documents' },
|
const isLoadingDocs = ref(true)
|
||||||
{ key: 'immeubles', label: 'Immeubles' },
|
const isLoadingRevenus = ref(true)
|
||||||
{ key: 'lots', label: 'Lots' },
|
const isLoadingTrends = ref(true)
|
||||||
{ key: 'locataires', label: 'Locataires' },
|
const isLoadingImmeubles = ref(true)
|
||||||
{ key: 'revenus', label: 'Revenus' },
|
|
||||||
{ key: 'depenses', label: 'Depenses' },
|
// 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) {
|
function formatDate(dateStr) {
|
||||||
if (!dateStr) return '-'
|
if (!dateStr) return '-'
|
||||||
@@ -115,24 +168,66 @@ function formatAmount(amount) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
isLoading.value = true
|
// Charger les documents
|
||||||
|
isLoadingDocs.value = true
|
||||||
try {
|
try {
|
||||||
const [statsRes, docsRes] = await Promise.all([
|
const docsRes = await fetch('/api/documents?limit=5')
|
||||||
fetch('/api/stats'),
|
|
||||||
fetch('/api/documents?limit=10')
|
|
||||||
])
|
|
||||||
|
|
||||||
if (statsRes.ok) {
|
|
||||||
stats.value = await statsRes.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (docsRes.ok) {
|
if (docsRes.ok) {
|
||||||
documents.value = await docsRes.json()
|
documents.value = await docsRes.json()
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load dashboard data:', err)
|
console.error('Failed to load documents:', err)
|
||||||
} finally {
|
} 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(() => {
|
onMounted(() => {
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
|
|
||||||
from .. import __version__
|
from .. import __version__
|
||||||
from ..database import init_db
|
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(
|
app = FastAPI(
|
||||||
title="Plesna Gérance API",
|
title="Plesna Gérance API",
|
||||||
@@ -32,6 +38,7 @@ app.include_router(extraction_router)
|
|||||||
app.include_router(documents_router)
|
app.include_router(documents_router)
|
||||||
app.include_router(tags_router)
|
app.include_router(tags_router)
|
||||||
app.include_router(analytics_router)
|
app.include_router(analytics_router)
|
||||||
|
app.include_router(dashboard_router)
|
||||||
|
|
||||||
|
|
||||||
# Health check endpoints (keep in main app)
|
# 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 .documents import router as documents_router
|
||||||
from .tags import router as tags_router
|
from .tags import router as tags_router
|
||||||
from .analytics import router as analytics_router
|
from .analytics import router as analytics_router
|
||||||
|
from .dashboard import router as dashboard_router
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"extraction_router",
|
"extraction_router",
|
||||||
"documents_router",
|
"documents_router",
|
||||||
"tags_router",
|
"tags_router",
|
||||||
"analytics_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