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>
|
||||
Reference in New Issue
Block a user