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:
2026-01-20 04:53:04 +01:00
parent cae8d5b963
commit 281f69f39e
9 changed files with 1275 additions and 70 deletions

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

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

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

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

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

View File

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

View File

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

View File

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

View 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