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>