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