feat: resume l'annee civile sur l'accueil

L'accueil ne decrivait qu'un document : les quatre tuiles lisaient le
dernier compte rendu importe, sans rien dire de ce que contient la base.

Il resume desormais une annee civile, avec un selecteur des annees
presentes. L'annee par defaut est celle du dernier compte rendu et non
l'annee en cours : la fenetre etait calee sur `date.today()`, si bien que
sans import depuis quelques mois l'ecran se serait vide alors que la base
est pleine.

Ce que les tuiles annoncent :

- recettes facturees, report exclu, avec l'encaisse et le recouvrement ;
- depenses, debit et credits recus ;
- net reverse, rapproche des soldes annonces par les comptes rendus ;
- restant du, date, car un stock ne se cumule pas d'un mois sur l'autre.

Le net reverse vaut « encaisse - debit + credit », et cette egalite tombe
au centime sur le solde extrait du PDF pour avril, mai et juin 2026. Elle
s'ecarte de 288,52 EUR en fevrier et de 1 188,03 EUR en mars, les deux mois
dont l'extraction a par ailleurs des defauts. L'ecart est donc affiche et
jamais lisse : c'est le seul controle de bout en bout dont on dispose sur
la qualite d'une extraction. Les regles vivent dans services/tresorerie.py,
a cote de celles des revenus.

Le lot compte est celui qui a ete facture dans l'annee : la table `lots`
retient deux ecritures par lot (« 0001 » et « 01 »), sequelle de la
normalisation des numeros, et en annoncerait 40 la ou il y en a 20.

Le graphique passe du vert et rouge au bleu et ambre : sous deuteranopie,
green-400 et red-400 ne se separent qu'a un delta E de 7,9, sous le seuil
de 8. La paire retenue tient a 30,2.

`/recent-revenus` disparait — c'etait le dernier agregat a compter les
lignes de report — et `/immeubles-shortcuts` est borne a la meme annee que
le reste de l'ecran, deux perimetres sur un ecran donnant deux montants
sans que rien ne les distingue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 13:51:59 +02:00
parent 424abbaf07
commit 9c2d4f7f82
12 changed files with 1068 additions and 853 deletions

View File

@@ -0,0 +1,137 @@
<template>
<div class="card card-body">
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<h2 class="card-title">Mois par mois</h2>
<!-- Deux series : la legende est toujours presente, l'identite ne tient
jamais a la seule couleur. -->
<div class="flex items-center gap-4 text-xs">
<span class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded" :style="{ backgroundColor: COULEURS.encaisse }"></span>
<span class="text-gray-400">Encaissé</span>
</span>
<span class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded" :style="{ backgroundColor: COULEURS.depenses }"></span>
<span class="text-gray-400">Dépenses</span>
</span>
</div>
</div>
<div v-if="loading" class="h-64 flex items-center justify-center">
<div class="spinner h-6 w-6"></div>
</div>
<div v-else-if="!data.length" class="h-64 flex items-center justify-center text-gray-500 text-sm">
Aucun compte rendu sur cette année.
</div>
<template v-else>
<div class="h-64">
<Bar :data="chartData" :options="chartOptions" />
</div>
<!-- Les mois absents sont dits, pas dessines a zero : un mois sans compte
rendu n'est pas un mois sans loyer. -->
<p class="mt-3 text-xs text-gray-500">
{{ data.length }} {{ data.length > 1 ? 'mois importés' : 'mois importé' }}.
Le net de chaque mois apparaît au survol.
</p>
</template>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Tooltip
} from 'chart.js'
import { formatMois, formatMontantPrecis } from '../../utils/format'
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip)
/**
* Bleu et ambre plutot que le vert/rouge habituel de l'application : sous
* deuteranopie, green-400 et red-400 ne se distinguent qu'a ΔE 7.9, sous le
* seuil de 8. Cette paire tient a 30.2, avec des luminosites comparables sur
* le fond sombre.
*/
const COULEURS = {
encaisse: '#3b82f6',
depenses: '#d97706'
}
const props = defineProps({
data: { type: Array, default: () => [] },
loading: { type: Boolean, default: false }
})
const chartData = computed(() => ({
labels: props.data.map((point) => formatMois(point.mois)),
datasets: [
{
label: 'Encaissé',
data: props.data.map((point) => point.encaisse),
backgroundColor: COULEURS.encaisse,
borderRadius: 4,
barPercentage: 0.8,
categoryPercentage: 0.7
},
{
label: 'Dépenses',
data: props.data.map((point) => point.depenses),
backgroundColor: COULEURS.depenses,
borderRadius: 4,
barPercentage: 0.8,
categoryPercentage: 0.7
}
]
}))
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
// Un espace de 2px entre deux barres voisines : les fills ne se touchent pas.
datasets: { bar: { borderSkipped: false } },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgb(31, 41, 55)',
borderColor: 'rgb(75, 85, 99)',
borderWidth: 1,
titleColor: 'rgb(255, 255, 255)',
bodyColor: 'rgb(209, 213, 219)',
footerColor: 'rgb(156, 163, 175)',
padding: 12,
callbacks: {
label: (context) =>
` ${context.dataset.label} : ${formatMontantPrecis(context.raw)}`,
// Le net est une grandeur derivee : il vit dans l'infobulle plutot que
// dans une troisieme serie, qui n'apporterait qu'une couleur de plus.
footer: (items) => {
const point = props.data[items[0].dataIndex]
return point ? `Net : ${formatMontantPrecis(point.net)}` : ''
}
}
}
},
scales: {
x: {
grid: { display: false },
ticks: { color: 'rgb(156, 163, 175)', font: { size: 11 } }
},
y: {
beginAtZero: true,
grid: { color: 'rgb(55, 65, 81)' },
ticks: {
color: 'rgb(156, 163, 175)',
font: { size: 11 },
callback: (valeur) => (valeur >= 1000 ? `${Math.round(valeur / 1000)} k€` : `${valeur}`)
}
}
}
}))
</script>

View File

@@ -0,0 +1,73 @@
<template>
<div class="card">
<div class="card-header">
<h2 class="card-title">Dépenses par poste</h2>
<button class="text-xs text-blue-400 hover:text-blue-300" @click="$emit('view-all')">
Tout voir
</button>
</div>
<div v-if="loading" class="p-8 flex justify-center">
<div class="spinner h-6 w-6"></div>
</div>
<div v-else-if="!postes.length" class="p-8 text-center text-gray-500 text-sm">
Aucune dépense sur cette année.
</div>
<div v-else class="card-body space-y-3">
<div v-for="poste in postes" :key="poste.tag_nom">
<div class="flex items-baseline justify-between gap-3 text-sm">
<span class="text-gray-300 truncate">{{ poste.tag_nom }}</span>
<span class="text-white font-medium shrink-0">{{ formatMontant(poste.total_debit) }}</span>
</div>
<div class="mt-1 flex items-center gap-2">
<!-- Une barre de proportion, pas un camembert : comparer des
longueurs sur une base commune se lit mieux que des angles. -->
<div class="flex-1 h-1.5 bg-gray-800 rounded-full overflow-hidden">
<div
class="h-full rounded-full"
:style="{ width: `${part(poste.total_debit)}%`, backgroundColor: COULEUR }"
></div>
</div>
<span class="text-xs text-gray-500 w-10 text-right">{{ formatPart(poste.total_debit) }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { formatMontant } from '../../utils/format'
/** Meme ambre que les depenses du graphique mensuel : un poste reste un poste. */
const COULEUR = '#d97706'
const props = defineProps({
data: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
limit: { type: Number, default: 6 }
})
defineEmits(['view-all'])
const postes = computed(() =>
props.data.filter((poste) => (poste.total_debit ?? 0) > 0).slice(0, props.limit)
)
const total = computed(() =>
props.data.reduce((somme, poste) => somme + (poste.total_debit ?? 0), 0)
)
function part(montant) {
if (!total.value) return 0
return Math.round((montant / total.value) * 100)
}
/** Un poste present ne s'affiche jamais « 0 % » : il pese peu, pas rien. */
function formatPart(montant) {
const pourcentage = part(montant)
return pourcentage < 1 ? '< 1 %' : `${pourcentage} %`
}
</script>

View File

@@ -1,181 +0,0 @@
<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="card card-body relative">
<!-- 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="card card-body relative">
<!-- 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="card card-body relative">
<!-- 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="card card-body relative">
<!-- 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

@@ -1,146 +0,0 @@
<template>
<div class="card">
<div class="card-header">
<h2 class="card-title">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="spinner h-6 w-6"></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,56 @@
<template>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="page-title">Recettes et dépenses {{ annee }}</h1>
<p class="page-subtitle">
<template v-if="perimetre.nb_comptes_rendus > 0">
{{ perimetre.nb_comptes_rendus }}
{{ perimetre.nb_comptes_rendus > 1 ? 'comptes rendus' : 'compte rendu' }}
du {{ formatDate(perimetre.premiere_date) }}
au {{ formatDate(perimetre.derniere_date) }}
&middot; {{ perimetre.nb_immeubles }}
{{ perimetre.nb_immeubles > 1 ? 'immeubles' : 'immeuble' }}
&middot; {{ perimetre.nb_lots_factures }} lots facturés
</template>
<template v-else>
Aucun compte rendu pour cette année.
</template>
</p>
</div>
<div v-if="annees.length > 1" class="flex items-center gap-2">
<label for="annee" class="text-xs text-gray-500">Année</label>
<select
id="annee"
class="input w-auto py-1.5"
:value="annee"
@change="$emit('change-annee', Number($event.target.value))"
>
<option v-for="option in annees" :key="option" :value="option">
{{ option }}
</option>
</select>
</div>
</div>
</template>
<script setup>
import { formatDate } from '../../utils/format'
defineProps({
annee: { type: Number, required: true },
annees: { type: Array, default: () => [] },
perimetre: {
type: Object,
default: () => ({
nb_comptes_rendus: 0,
premiere_date: null,
derniere_date: null,
nb_immeubles: 0,
nb_lots_factures: 0
})
}
})
defineEmits(['change-annee'])
</script>

View File

@@ -1,100 +0,0 @@
<template>
<div class="card">
<div class="card-header">
<h2 class="card-title">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="spinner h-6 w-6"></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

@@ -0,0 +1,97 @@
<template>
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
<!-- Revenus : facturé en gros, encaissé et recouvrement dessous -->
<div class="card card-body">
<div class="text-xs text-gray-400 uppercase tracking-wide mb-2">Recettes facturées</div>
<div class="text-2xl font-bold text-white">{{ formatMontant(resume.facture) }}</div>
<dl class="mt-2 space-y-0.5 text-xs">
<div class="flex justify-between gap-2">
<dt class="text-gray-500">Encaissé</dt>
<dd class="text-gray-300">{{ formatMontant(resume.encaisse) }}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-gray-500">Recouvrement</dt>
<dd class="text-gray-300">{{ formatTaux(resume.taux_recouvrement) }}</dd>
</div>
</dl>
</div>
<!-- Dépenses -->
<div class="card card-body">
<div class="text-xs text-gray-400 uppercase tracking-wide mb-2">Dépenses</div>
<div class="text-2xl font-bold text-white">{{ formatMontant(resume.depenses_debit) }}</div>
<dl class="mt-2 space-y-0.5 text-xs">
<div class="flex justify-between gap-2">
<dt class="text-gray-500">Crédits reçus</dt>
<dd class="text-gray-300">{{ formatMontant(resume.depenses_credit) }}</dd>
</div>
</dl>
</div>
<!-- Net reversé, rapproché des soldes annoncés par les comptes rendus -->
<div class="card card-body">
<div class="text-xs text-gray-400 uppercase tracking-wide mb-2">Net reversé</div>
<div
class="text-2xl font-bold"
:class="resume.net_reverse >= 0 ? 'text-white' : 'text-red-400'"
>
{{ formatMontant(resume.net_reverse) }}
</div>
<dl class="mt-2 space-y-0.5 text-xs">
<div class="flex justify-between gap-2">
<dt class="text-gray-500">Soldes des CR</dt>
<dd class="text-gray-300">{{ formatMontant(resume.soldes_annonces) }}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-gray-500">Écart</dt>
<dd :class="ecartNul ? 'text-gray-300' : 'text-amber-400 font-medium'">
{{ ecartNul ? 'aucun' : formatEcart(resume.ecart_soldes) }}
</dd>
</div>
</dl>
</div>
<!-- Restant : un stock. La date et la mention « non cumulable » tiennent
sur les deux memes lignes que les autres tuiles ; une phrase plus
longue serait tronquee par l'`overflow-hidden` de `.card`. -->
<div class="card card-body">
<div class="text-xs text-gray-400 uppercase tracking-wide mb-2">Restant dû</div>
<div
class="text-2xl font-bold"
:class="resume.restant_du > 0 ? 'text-amber-400' : 'text-white'"
>
{{ formatMontant(resume.restant_du) }}
</div>
<dl class="mt-2 space-y-0.5 text-xs">
<div class="flex justify-between gap-2">
<dt class="text-gray-500">Au</dt>
<dd class="text-gray-300">{{ formatDate(resume.restant_du_date) }}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-gray-500">Nature</dt>
<dd class="text-gray-300" title="Dette en cours : elle ne s'additionne pas d'un mois sur l'autre.">
dette en cours
</dd>
</div>
</dl>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { formatDate, formatEcart, formatMontant } from '../../utils/format'
const props = defineProps({
resume: { type: Object, required: true }
})
/** Sous le centime, l'ecart tient a l'arrondi et non a une anomalie. */
const ecartNul = computed(() => Math.abs(props.resume.ecart_soldes ?? 0) < 0.01)
function formatTaux(valeur) {
if (valeur == null) return '—'
return `${valeur.toFixed(1).replace('.', ',')} %`
}
</script>

View File

@@ -2,6 +2,13 @@
<div class="page">
<div class="page-content">
<PeriodeBandeau
:annee="resume.annee"
:annees="resume.annees_disponibles"
:perimetre="resume.perimetre"
@change-annee="changerAnnee"
/>
<!-- Zone d'upload PDF visible -->
<div
class="border-2 border-dashed border-gray-700 rounded-lg p-6 text-center hover:border-blue-500 transition-colors cursor-pointer bg-gray-900/50"
@@ -24,95 +31,104 @@
</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">Deposer le fichier ici</span>
<span v-else class="text-blue-400 font-medium">Déposer le fichier ici</span>
</p>
</div>
</div>
<!-- Actions rapides -->
<QuickActions @import-pdf="triggerFileInput" />
<QuickActions />
<!-- Resume financier -->
<FinancialSummary :data="financialSummary" />
<!-- Resume de l'annee -->
<div v-if="isLoadingResume" class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
<div v-for="n in 4" :key="n" class="card card-body h-28 flex items-center justify-center">
<div class="spinner h-5 w-5"></div>
</div>
</div>
<ResumeTuiles v-else :resume="resume" />
<AnnualChart :data="resume.par_mois" :loading="isLoadingResume" />
<!-- Grille principale -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Colonne gauche: Graphique tendance + Revenus recents -->
<div class="space-y-6">
<MiniTrendChart :trends="monthlyTrends" :loading="isLoadingTrends" />
<RecentRevenus :revenus="recentRevenus" :loading="isLoadingRevenus" />
</div>
<!-- Colonne droite: Immeubles + Documents -->
<div class="space-y-6">
<ImmeubleShortcuts
:immeubles="immeubleShortcuts"
<DepensesParTag
:data="depensesParTag"
:loading="isLoadingDepenses"
@view-all="router.push('/analytics')"
/>
<!-- Les raccourcis n'ont de sens qu'a plusieurs immeubles : avec un
seul, ils repeteraient les tuiles ci-dessus. -->
<ImmeubleShortcuts
v-if="immeubleShortcuts.length > 1"
:immeubles="immeubleShortcuts"
:loading="isLoadingImmeubles"
@select="handleImmeubleSelect"
@view-all="goToAnalytics"
/>
</div>
<!-- Derniers documents -->
<div id="recent-documents" class="card">
<div class="card-header">
<h2 class="card-title">Derniers documents importes</h2>
<span class="text-xs text-gray-500">{{ documents.length }} documents</span>
</div>
<!-- Derniers documents -->
<div class="card">
<div class="card-header">
<h2 class="card-title">Derniers documents importés</h2>
<span class="text-xs text-gray-500">{{ documents.length }} affichés</span>
</div>
<div v-if="isLoadingDocs" class="p-8 text-center text-gray-500">
<div class="spinner h-6 w-6"></div>
</div>
<div v-if="isLoadingDocs" class="p-8 text-center text-gray-500">
<div class="spinner h-6 w-6"></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',
isSoldeCrediteur(doc.solde_type) ? 'text-green-400' : 'text-red-400'
]"
>
{{ formatAmount(doc.solde_montant) }}
<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 importé.</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',
isSoldeCrediteur(doc.solde_type) ? 'text-green-400' : 'text-red-400'
]"
>
{{ formatMontantPrecis(doc.solde_montant) }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -122,11 +138,13 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { pendingFile } from '../store'
import { isSoldeCrediteur } from '../utils/solde'
import { formatDate, formatMontantPrecis } from '../utils/format'
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 PeriodeBandeau from '../components/dashboard/PeriodeBandeau.vue'
import ResumeTuiles from '../components/dashboard/ResumeTuiles.vue'
import AnnualChart from '../components/dashboard/AnnualChart.vue'
import DepensesParTag from '../components/dashboard/DepensesParTag.vue'
import ImmeubleShortcuts from '../components/dashboard/ImmeubleShortcuts.vue'
const router = useRouter()
@@ -135,101 +153,93 @@ const isDragging = ref(false)
// Loading states
const isLoadingDocs = ref(true)
const isLoadingRevenus = ref(true)
const isLoadingTrends = ref(true)
const isLoadingResume = ref(true)
const isLoadingDepenses = 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 depensesParTag = ref([])
const immeubleShortcuts = ref([])
const resume = ref({
annee: new Date().getFullYear(),
annees_disponibles: [],
perimetre: {
nb_comptes_rendus: 0,
premiere_date: null,
derniere_date: null,
nb_immeubles: 0,
nb_lots_factures: 0
},
facture: 0,
encaisse: 0,
taux_recouvrement: 100,
depenses_debit: 0,
depenses_credit: 0,
net_reverse: 0,
soldes_annonces: 0,
ecart_soldes: 0,
restant_du: 0,
restant_du_date: null,
par_mois: []
})
function formatDate(dateStr) {
if (!dateStr) return '-'
const [year, month, day] = dateStr.split('-')
return `${day}/${month}/${year}`
async function chargerJson(url) {
try {
const reponse = await fetch(url)
return reponse.ok ? await reponse.json() : null
} catch (err) {
console.error(`Echec du chargement de ${url}:`, err)
return null
}
}
function formatAmount(amount) {
if (amount == null) return '-'
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(amount)
/**
* Le resume est charge en premier : il fixe l'annee affichee, dont depend le
* cadrage des depenses et des raccourcis. Les charger avant reviendrait a
* afficher trois periodes differentes sur le meme ecran.
*/
async function chargerResume(annee) {
isLoadingResume.value = true
const url = annee
? `/api/dashboard/resume-annuel?annee=${annee}`
: '/api/dashboard/resume-annuel'
const data = await chargerJson(url)
if (data) resume.value = data
isLoadingResume.value = false
return resume.value.annee
}
async function loadData() {
// Charger les documents
isLoadingDocs.value = true
try {
const docsRes = await fetch('/api/documents?limit=5')
if (docsRes.ok) {
documents.value = await docsRes.json()
}
} catch (err) {
console.error('Failed to load documents:', err)
} finally {
isLoadingDocs.value = false
}
async function chargerDepenses(annee) {
isLoadingDepenses.value = true
const data = await chargerJson(
`/api/analytics/depenses/summary?date_debut=${annee}-01-01&date_fin=${annee}-12-31`
)
depensesParTag.value = data?.by_tag ?? []
isLoadingDepenses.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
async function chargerImmeubles(annee) {
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
}
const data = await chargerJson(`/api/dashboard/immeubles-shortcuts?annee=${annee}&limit=6`)
immeubleShortcuts.value = data ?? []
isLoadingImmeubles.value = false
}
async function chargerDocuments() {
isLoadingDocs.value = true
const data = await chargerJson('/api/documents?limit=5')
documents.value = data ?? []
isLoadingDocs.value = false
}
async function chargerAnnee(annee) {
const affichee = await chargerResume(annee)
await Promise.all([chargerDepenses(affichee), chargerImmeubles(affichee)])
}
function changerAnnee(annee) {
chargerAnnee(annee)
}
function triggerFileInput() {
@@ -263,6 +273,7 @@ function goToAnalytics() {
}
onMounted(() => {
loadData()
chargerDocuments()
chargerAnnee(null)
})
</script>

View File

@@ -0,0 +1,56 @@
/**
* Mises en forme partagées des montants et des dates.
*
* Chaque composant redéfinissait son `formatAmount`, avec des variantes
* d'arrondi et de séparateur : deux écrans pouvaient écrire différemment le
* même euro. Les nouveaux affichages passent par ici.
*/
const EUROS = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0
})
const EUROS_PRECIS = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
})
const MOIS_COURTS = [
'janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin',
'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.'
]
/** Montant arrondi à l'euro, pour les chiffres qu'on lit d'un coup d'œil. */
export function formatMontant(valeur) {
if (valeur == null) return '—'
return EUROS.format(valeur)
}
/** Montant au centime, pour les écarts et les rapprochements. */
export function formatMontantPrecis(valeur) {
if (valeur == null) return '—'
return EUROS_PRECIS.format(valeur)
}
/** Montant signé, le « + » explicite pour ce qui revient au propriétaire. */
export function formatEcart(valeur) {
if (valeur == null) return '—'
const signe = valeur > 0 ? '+' : ''
return `${signe}${EUROS_PRECIS.format(valeur)}`
}
/** "2026-06-22" → "22/06/2026" */
export function formatDate(chaine) {
if (!chaine) return '—'
const [annee, mois, jour] = chaine.split('-')
return `${jour}/${mois}/${annee}`
}
/** "2026-06" → "juin" (l'année est portée par le titre de la période) */
export function formatMois(chaine) {
if (!chaine) return ''
const [, mois] = chaine.split('-')
return MOIS_COURTS[parseInt(mois, 10) - 1] ?? chaine
}