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:
137
frontend/src/components/dashboard/AnnualChart.vue
Normal file
137
frontend/src/components/dashboard/AnnualChart.vue
Normal 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>
|
||||
73
frontend/src/components/dashboard/DepensesParTag.vue
Normal file
73
frontend/src/components/dashboard/DepensesParTag.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
56
frontend/src/components/dashboard/PeriodeBandeau.vue
Normal file
56
frontend/src/components/dashboard/PeriodeBandeau.vue
Normal 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) }}
|
||||
· {{ perimetre.nb_immeubles }}
|
||||
{{ perimetre.nb_immeubles > 1 ? 'immeubles' : 'immeuble' }}
|
||||
· {{ 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>
|
||||
@@ -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>
|
||||
97
frontend/src/components/dashboard/ResumeTuiles.vue
Normal file
97
frontend/src/components/dashboard/ResumeTuiles.vue
Normal 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 dû : 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>
|
||||
@@ -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>
|
||||
|
||||
56
frontend/src/utils/format.js
Normal file
56
frontend/src/utils/format.js
Normal 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
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Dashboard routes - Aggregated data for the home page."""
|
||||
"""Dashboard routes - Vue annuelle des revenus et des depenses."""
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import date, timedelta
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -17,7 +16,13 @@ from ...database.models import (
|
||||
Lot,
|
||||
Revenu,
|
||||
)
|
||||
from ...services.revenus_query import est_flux, flux_par, restant_du_par
|
||||
from ...services.revenus_query import (
|
||||
est_flux,
|
||||
flux_par,
|
||||
restant_du_par,
|
||||
taux_de_recouvrement,
|
||||
)
|
||||
from ...services.tresorerie import depenses_par, net_reverse, somme_soldes_annonces
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||
|
||||
@@ -27,50 +32,52 @@ router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||
# ============================================================
|
||||
|
||||
|
||||
class MonthlyDataPoint(BaseModel):
|
||||
"""Point de donnees mensuel pour sparkline."""
|
||||
class PerimetreResponse(BaseModel):
|
||||
"""De quoi l'annee affichee est faite."""
|
||||
|
||||
month: str
|
||||
value: float
|
||||
nb_comptes_rendus: int
|
||||
premiere_date: str | None = None
|
||||
derniere_date: str | None = None
|
||||
nb_immeubles: int
|
||||
#: Lots effectivement factures dans l'annee. La table `lots` en contient
|
||||
#: davantage : d'anciennes ecritures de numeros y subsistent sans revenu.
|
||||
nb_lots_factures: int
|
||||
|
||||
|
||||
class FinancialSummaryResponse(BaseModel):
|
||||
"""Resume financier du dernier document avec historique pour sparklines."""
|
||||
class MoisResponse(BaseModel):
|
||||
"""Un mois de l'annee, tel qu'il se compare aux autres."""
|
||||
|
||||
# Valeurs du dernier document
|
||||
last_document_date: str | None = None
|
||||
last_document_reference: str | None = None
|
||||
revenus: float
|
||||
impayes: float
|
||||
mois: str # "2026-06"
|
||||
facture: float
|
||||
encaisse: float
|
||||
depenses: float
|
||||
solde: float
|
||||
# Historique pour sparklines (6 derniers mois)
|
||||
revenus_history: list[MonthlyDataPoint] = []
|
||||
impayes_history: list[MonthlyDataPoint] = []
|
||||
depenses_history: list[MonthlyDataPoint] = []
|
||||
solde_history: list[MonthlyDataPoint] = []
|
||||
net: float
|
||||
|
||||
|
||||
class RecentRevenuResponse(BaseModel):
|
||||
"""Revenu recent avec details."""
|
||||
class ResumeAnnuelResponse(BaseModel):
|
||||
"""Revenus et depenses de l'annee civile, avec leur contrepartie annoncee."""
|
||||
|
||||
id: int
|
||||
document_date: str
|
||||
locataire_nom: str
|
||||
lot_numero: str
|
||||
immeuble_code: str
|
||||
total: float
|
||||
impayes: float
|
||||
type_ligne: str
|
||||
annee: int
|
||||
annees_disponibles: list[int]
|
||||
perimetre: PerimetreResponse
|
||||
|
||||
# Flux cumules sur l'annee, report exclu (cf. services/revenus_query)
|
||||
facture: float
|
||||
encaisse: float
|
||||
taux_recouvrement: float
|
||||
depenses_debit: float
|
||||
depenses_credit: float
|
||||
|
||||
class MonthlyTrendResponse(BaseModel):
|
||||
"""Tendance mensuelle pour graphique."""
|
||||
# Ce qui revient au proprietaire, et l'ecart avec les soldes des CR
|
||||
net_reverse: float
|
||||
soldes_annonces: float
|
||||
ecart_soldes: float
|
||||
|
||||
month: str # "2024-01"
|
||||
revenus: float
|
||||
depenses: float
|
||||
solde: float
|
||||
# Stock : photo du dernier compte rendu de l'annee, non cumulable
|
||||
restant_du: float
|
||||
restant_du_date: str | None = None
|
||||
|
||||
par_mois: list[MoisResponse] = []
|
||||
|
||||
|
||||
class ImmeubleShortcutResponse(BaseModel):
|
||||
@@ -91,255 +98,200 @@ class ImmeubleShortcutResponse(BaseModel):
|
||||
# ============================================================
|
||||
|
||||
|
||||
@router.get("/financial-summary", response_model=FinancialSummaryResponse)
|
||||
async def get_financial_summary(
|
||||
session: Session = Depends(get_session),
|
||||
) -> FinancialSummaryResponse:
|
||||
"""Retourne le resume financier du dernier document avec historique pour sparklines.
|
||||
def _annees_disponibles(session: Session) -> list[int]:
|
||||
"""Annees ayant au moins un compte rendu, de la plus recente a la plus ancienne."""
|
||||
# `select(colonne).distinct()` plutot que `func.distinct(colonne)` : le
|
||||
# second perd le type de la colonne et rendrait les dates sous forme de
|
||||
# chaines, sans annee a lire.
|
||||
dates = session.execute(select(Document.date).distinct()).scalars().all()
|
||||
return sorted({jour.year for jour in dates}, reverse=True)
|
||||
|
||||
- Valeurs principales basees sur le dernier document importe
|
||||
- Historique sur 6 mois pour les sparklines
|
||||
|
||||
def _annee_par_defaut(session: Session, annee: int | None) -> tuple[int, list[int]]:
|
||||
"""Annee a afficher et annees proposables.
|
||||
|
||||
Le defaut est l'annee du dernier compte rendu, et non l'annee en cours :
|
||||
sans import depuis quelques mois, un accueil cale sur la date du jour se
|
||||
viderait alors que la base est pleine.
|
||||
"""
|
||||
# Recuperer le dernier document
|
||||
last_doc_stmt = select(Document).order_by(desc(Document.date)).limit(1)
|
||||
last_doc = session.execute(last_doc_stmt).scalar()
|
||||
annees = _annees_disponibles(session)
|
||||
if annee is None:
|
||||
annee = annees[0] if annees else date.today().year
|
||||
return annee, annees
|
||||
|
||||
# Valeurs du dernier document
|
||||
revenus = 0.0
|
||||
impayes = 0.0
|
||||
depenses = 0.0
|
||||
last_document_date = None
|
||||
last_document_reference = None
|
||||
|
||||
if last_doc:
|
||||
last_document_date = str(last_doc.date)
|
||||
last_document_reference = last_doc.reference
|
||||
@router.get("/resume-annuel", response_model=ResumeAnnuelResponse)
|
||||
async def get_resume_annuel(
|
||||
annee: int | None = Query(None, description="Annee civile a afficher"),
|
||||
session: Session = Depends(get_session),
|
||||
) -> ResumeAnnuelResponse:
|
||||
"""Retourne les revenus et depenses d'une annee civile.
|
||||
|
||||
# Revenus du dernier document : le solde reporte du mois precedent n'est
|
||||
# pas un revenu du mois, il est deja compte dans les impayes.
|
||||
revenus = (
|
||||
session.execute(
|
||||
select(func.sum(Revenu.total))
|
||||
.where(Revenu.document_id == last_doc.id)
|
||||
.where(est_flux())
|
||||
).scalar()
|
||||
or 0.0
|
||||
Les montants cumulables (facture, encaisse, depenses) sont bornes a l'annee ;
|
||||
le restant du est lu dans le dernier compte rendu de la periode, car un stock
|
||||
ne s'additionne pas dans le temps (cf. services/revenus_query).
|
||||
"""
|
||||
annee, annees = _annee_par_defaut(session, annee)
|
||||
debut = date(annee, 1, 1)
|
||||
fin = date(annee, 12, 31)
|
||||
|
||||
perimetre = _perimetre(session, debut, fin)
|
||||
|
||||
# ========== Flux de l'annee ==========
|
||||
flux = flux_par(Document.immeuble_id, debut, fin)
|
||||
totaux = session.execute(
|
||||
select(
|
||||
func.sum(flux.c.facture).label("facture"),
|
||||
func.sum(flux.c.encaisse).label("encaisse"),
|
||||
func.sum(flux.c.facture_regle).label("facture_regle"),
|
||||
)
|
||||
).one()
|
||||
|
||||
# Impayes du dernier document
|
||||
impayes = (
|
||||
session.execute(
|
||||
select(func.sum(Revenu.impayes)).where(
|
||||
Revenu.document_id == last_doc.id
|
||||
)
|
||||
).scalar()
|
||||
or 0.0
|
||||
depenses = depenses_par(Document.immeuble_id, debut, fin)
|
||||
cumul = session.execute(
|
||||
select(
|
||||
func.sum(depenses.c.debit).label("debit"),
|
||||
func.sum(depenses.c.credit).label("credit"),
|
||||
)
|
||||
).one()
|
||||
|
||||
# Depenses du dernier document
|
||||
depenses = (
|
||||
session.execute(
|
||||
select(func.sum(Depense.debit)).where(
|
||||
Depense.document_id == last_doc.id
|
||||
)
|
||||
).scalar()
|
||||
or 0.0
|
||||
)
|
||||
facture = totaux.facture or 0.0
|
||||
encaisse = totaux.encaisse or 0.0
|
||||
debit = cumul.debit or 0.0
|
||||
credit = cumul.credit or 0.0
|
||||
|
||||
solde = revenus - depenses
|
||||
net = net_reverse(encaisse, debit, credit)
|
||||
annonces = somme_soldes_annonces(session, debut, fin)
|
||||
|
||||
# Historique sur 6 mois pour sparklines
|
||||
today = date.today()
|
||||
start_date = (today.replace(day=1) - timedelta(days=6 * 31)).replace(day=1)
|
||||
# ========== Restant du (stock) ==========
|
||||
stock = restant_du_par(Document.immeuble_id, debut, fin)
|
||||
restant_du = session.execute(select(func.sum(stock.c.restant_du))).scalar() or 0.0
|
||||
|
||||
# Revenus par mois
|
||||
revenus_by_month: dict[str, float] = defaultdict(float)
|
||||
impayes_by_month: dict[str, float] = defaultdict(float)
|
||||
depenses_by_month: dict[str, float] = defaultdict(float)
|
||||
return ResumeAnnuelResponse(
|
||||
annee=annee,
|
||||
annees_disponibles=annees,
|
||||
perimetre=perimetre,
|
||||
facture=round(facture, 2),
|
||||
encaisse=round(encaisse, 2),
|
||||
taux_recouvrement=taux_de_recouvrement(facture, totaux.facture_regle),
|
||||
depenses_debit=round(debit, 2),
|
||||
depenses_credit=round(credit, 2),
|
||||
net_reverse=net,
|
||||
soldes_annonces=annonces,
|
||||
ecart_soldes=round(net - annonces, 2),
|
||||
restant_du=round(restant_du, 2),
|
||||
restant_du_date=perimetre.derniere_date,
|
||||
par_mois=_par_mois(session, debut, fin),
|
||||
)
|
||||
|
||||
# Recuperer revenus et impayes par mois. Le revenu du mois exclut le report
|
||||
# pour que les mois soient comparables ; l'impaye reste le solde constate ce
|
||||
# mois-la, de sorte que la courbe suive la dette au lieu de l'empiler.
|
||||
revenus_stmt = (
|
||||
|
||||
def _perimetre(session: Session, debut: date, fin: date) -> PerimetreResponse:
|
||||
"""Ce que couvre l'annee : combien de comptes rendus, sur quoi, jusqu'a quand."""
|
||||
documents = session.execute(
|
||||
select(
|
||||
func.count(Document.id).label("nb"),
|
||||
func.min(Document.date).label("premiere"),
|
||||
func.max(Document.date).label("derniere"),
|
||||
func.count(func.distinct(Document.immeuble_id)).label("nb_immeubles"),
|
||||
).where(Document.date.between(debut, fin))
|
||||
).one()
|
||||
|
||||
# Les lots comptes sont ceux qui ont ete factures dans l'annee : un lot sorti
|
||||
# de la gestion ne fait plus partie du perimetre qu'on resume, et la table
|
||||
# `lots` retient par ailleurs d'anciennes ecritures de numeros sans revenu.
|
||||
nb_lots_factures = (
|
||||
session.execute(
|
||||
select(func.count(func.distinct(Revenu.lot_id)))
|
||||
.join(Document, Revenu.document_id == Document.id)
|
||||
.where(Document.date.between(debut, fin))
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
return PerimetreResponse(
|
||||
nb_comptes_rendus=documents.nb or 0,
|
||||
premiere_date=str(documents.premiere) if documents.premiere else None,
|
||||
derniere_date=str(documents.derniere) if documents.derniere else None,
|
||||
nb_immeubles=documents.nb_immeubles or 0,
|
||||
nb_lots_factures=nb_lots_factures,
|
||||
)
|
||||
|
||||
|
||||
def _par_mois(session: Session, debut: date, fin: date) -> list[MoisResponse]:
|
||||
"""Detail mensuel de l'annee, un point par mois ayant un compte rendu.
|
||||
|
||||
Les mois sans compte rendu sont omis plutot que mis a zero : un mois vide
|
||||
est un mois non importe, pas un mois sans loyer, et une barre a zero le
|
||||
ferait lire comme une chute d'activite.
|
||||
|
||||
L'encaisse retient toutes les lignes, report compris : regler une vieille
|
||||
dette est bien un encaissement du mois. Le facture, lui, exclut le report,
|
||||
sans quoi les mois cesseraient d'etre comparables.
|
||||
"""
|
||||
revenus = session.execute(
|
||||
select(
|
||||
Document.date,
|
||||
func.sum(Revenu.total).filter(est_flux()).label("total"),
|
||||
func.sum(Revenu.impayes).label("impayes"),
|
||||
func.sum(Revenu.total).filter(est_flux()).label("facture"),
|
||||
func.sum(Revenu.regles).label("encaisse"),
|
||||
)
|
||||
.join(Revenu, Revenu.document_id == Document.id)
|
||||
.where(Document.date >= start_date)
|
||||
.where(Document.date.between(debut, fin))
|
||||
.group_by(Document.date)
|
||||
)
|
||||
for row in session.execute(revenus_stmt):
|
||||
month_key = row.date.strftime("%Y-%m")
|
||||
revenus_by_month[month_key] += row.total or 0.0
|
||||
impayes_by_month[month_key] += row.impayes or 0.0
|
||||
).all()
|
||||
|
||||
# Recuperer depenses par mois
|
||||
depenses_stmt = (
|
||||
select(Document.date, func.sum(Depense.debit).label("total"))
|
||||
.join(Depense, Depense.document_id == Document.id)
|
||||
.where(Document.date >= start_date)
|
||||
.group_by(Document.date)
|
||||
)
|
||||
for row in session.execute(depenses_stmt):
|
||||
month_key = row.date.strftime("%Y-%m")
|
||||
depenses_by_month[month_key] += row.total or 0.0
|
||||
|
||||
# Generer les 6 derniers mois
|
||||
all_months = []
|
||||
current = today.replace(day=1)
|
||||
for _ in range(6):
|
||||
all_months.insert(0, current.strftime("%Y-%m"))
|
||||
current = (current - timedelta(days=1)).replace(day=1)
|
||||
|
||||
# Construire les listes d'historique
|
||||
revenus_history = [
|
||||
MonthlyDataPoint(month=m, value=revenus_by_month.get(m, 0.0))
|
||||
for m in all_months
|
||||
]
|
||||
impayes_history = [
|
||||
MonthlyDataPoint(month=m, value=impayes_by_month.get(m, 0.0))
|
||||
for m in all_months
|
||||
]
|
||||
depenses_history = [
|
||||
MonthlyDataPoint(month=m, value=depenses_by_month.get(m, 0.0))
|
||||
for m in all_months
|
||||
]
|
||||
solde_history = [
|
||||
MonthlyDataPoint(
|
||||
month=m,
|
||||
value=revenus_by_month.get(m, 0.0) - depenses_by_month.get(m, 0.0),
|
||||
)
|
||||
for m in all_months
|
||||
]
|
||||
|
||||
return FinancialSummaryResponse(
|
||||
last_document_date=last_document_date,
|
||||
last_document_reference=last_document_reference,
|
||||
revenus=revenus,
|
||||
impayes=impayes,
|
||||
depenses=depenses,
|
||||
solde=solde,
|
||||
revenus_history=revenus_history,
|
||||
impayes_history=impayes_history,
|
||||
depenses_history=depenses_history,
|
||||
solde_history=solde_history,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/recent-revenus", response_model=list[RecentRevenuResponse])
|
||||
async def get_recent_revenus(
|
||||
limit: int = 10,
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[RecentRevenuResponse]:
|
||||
"""Retourne les derniers revenus/loyers enregistres.
|
||||
|
||||
- **limit**: Nombre maximum de resultats (defaut: 10)
|
||||
"""
|
||||
stmt = (
|
||||
depenses = session.execute(
|
||||
select(
|
||||
Revenu,
|
||||
Document.date.label("document_date"),
|
||||
Locataire.nom.label("locataire_nom"),
|
||||
Lot.numero.label("lot_numero"),
|
||||
Immeuble.code.label("immeuble_code"),
|
||||
Document.date,
|
||||
func.sum(Depense.debit).label("debit"),
|
||||
func.sum(Depense.credit).label("credit"),
|
||||
)
|
||||
.join(Document, Revenu.document_id == Document.id)
|
||||
.join(Locataire, Revenu.locataire_id == Locataire.id)
|
||||
.join(Lot, Revenu.lot_id == Lot.id)
|
||||
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
|
||||
.order_by(desc(Document.date), desc(Revenu.id))
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
result = session.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
RecentRevenuResponse(
|
||||
id=row.Revenu.id,
|
||||
document_date=str(row.document_date),
|
||||
locataire_nom=row.locataire_nom,
|
||||
lot_numero=row.lot_numero,
|
||||
immeuble_code=row.immeuble_code,
|
||||
total=row.Revenu.total or 0.0,
|
||||
impayes=row.Revenu.impayes or 0.0,
|
||||
type_ligne=row.Revenu.type_ligne or "",
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/monthly-trends", response_model=list[MonthlyTrendResponse])
|
||||
async def get_monthly_trends(
|
||||
months: int = 6,
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[MonthlyTrendResponse]:
|
||||
"""Retourne les tendances mensuelles pour le graphique.
|
||||
|
||||
- **months**: Nombre de mois a inclure (defaut: 6)
|
||||
"""
|
||||
today = date.today()
|
||||
start_date = (today.replace(day=1) - timedelta(days=months * 31)).replace(day=1)
|
||||
|
||||
# Recuperer tous les revenus depuis start_date, report exclu : la courbe
|
||||
# compare des mois entre eux, pas des soldes cumules.
|
||||
revenus_stmt = (
|
||||
select(Document.date, func.sum(Revenu.total).filter(est_flux()).label("total"))
|
||||
.join(Revenu, Revenu.document_id == Document.id)
|
||||
.where(Document.date >= start_date)
|
||||
.group_by(Document.date)
|
||||
)
|
||||
|
||||
revenus_result = session.execute(revenus_stmt)
|
||||
revenus_by_month: dict[str, float] = defaultdict(float)
|
||||
for row in revenus_result:
|
||||
month_key = row.date.strftime("%Y-%m")
|
||||
revenus_by_month[month_key] += row.total or 0.0
|
||||
|
||||
# Recuperer toutes les depenses depuis start_date
|
||||
depenses_stmt = (
|
||||
select(Document.date, func.sum(Depense.debit).label("total"))
|
||||
.join(Depense, Depense.document_id == Document.id)
|
||||
.where(Document.date >= start_date)
|
||||
.where(Document.date.between(debut, fin))
|
||||
.group_by(Document.date)
|
||||
)
|
||||
).all()
|
||||
|
||||
depenses_result = session.execute(depenses_stmt)
|
||||
depenses_by_month: dict[str, float] = defaultdict(float)
|
||||
for row in depenses_result:
|
||||
month_key = row.date.strftime("%Y-%m")
|
||||
depenses_by_month[month_key] += row.total or 0.0
|
||||
|
||||
# Combiner et trier
|
||||
all_months = sorted(set(revenus_by_month.keys()) | set(depenses_by_month.keys()))
|
||||
|
||||
# Limiter aux derniers mois demandes
|
||||
all_months = all_months[-months:]
|
||||
mois: dict[str, dict[str, float]] = {}
|
||||
for ligne in revenus:
|
||||
cumul = mois.setdefault(ligne.date.strftime("%Y-%m"), {})
|
||||
cumul["facture"] = cumul.get("facture", 0.0) + (ligne.facture or 0.0)
|
||||
cumul["encaisse"] = cumul.get("encaisse", 0.0) + (ligne.encaisse or 0.0)
|
||||
for ligne in depenses:
|
||||
cumul = mois.setdefault(ligne.date.strftime("%Y-%m"), {})
|
||||
cumul["debit"] = cumul.get("debit", 0.0) + (ligne.debit or 0.0)
|
||||
cumul["credit"] = cumul.get("credit", 0.0) + (ligne.credit or 0.0)
|
||||
|
||||
return [
|
||||
MonthlyTrendResponse(
|
||||
month=month,
|
||||
revenus=revenus_by_month.get(month, 0.0),
|
||||
depenses=depenses_by_month.get(month, 0.0),
|
||||
solde=revenus_by_month.get(month, 0.0) - depenses_by_month.get(month, 0.0),
|
||||
MoisResponse(
|
||||
mois=cle,
|
||||
facture=round(cumul.get("facture", 0.0), 2),
|
||||
encaisse=round(cumul.get("encaisse", 0.0), 2),
|
||||
depenses=round(cumul.get("debit", 0.0), 2),
|
||||
net=net_reverse(
|
||||
cumul.get("encaisse"), cumul.get("debit"), cumul.get("credit")
|
||||
),
|
||||
)
|
||||
for month in all_months
|
||||
for cle, cumul in sorted(mois.items())
|
||||
]
|
||||
|
||||
|
||||
@router.get("/immeubles-shortcuts", response_model=list[ImmeubleShortcutResponse])
|
||||
async def get_immeubles_shortcuts(
|
||||
annee: int | None = Query(None, description="Annee civile a afficher"),
|
||||
limit: int = 5,
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[ImmeubleShortcutResponse]:
|
||||
"""Retourne les immeubles pour acces rapide avec stats.
|
||||
|
||||
Trie par nombre de documents (plus actifs en premier).
|
||||
Trie par nombre de documents (plus actifs en premier). Borne sur la meme
|
||||
annee que le resume : deux perimetres sur un meme ecran donneraient deux
|
||||
montants de revenus sans que rien ne les distingue.
|
||||
|
||||
- **limit**: Nombre maximum d'immeubles (defaut: 5)
|
||||
"""
|
||||
annee, _ = _annee_par_defaut(session, annee)
|
||||
debut = date(annee, 1, 1)
|
||||
fin = date(annee, 12, 31)
|
||||
|
||||
# Effectifs et activite, puis flux et restant du : trois granularites
|
||||
# differentes, jointes plutot que melangees pour ne pas se multiplier.
|
||||
effectifs = (
|
||||
@@ -357,12 +309,15 @@ async def get_immeubles_shortcuts(
|
||||
Document.immeuble_id.label("immeuble_id"),
|
||||
func.count(Document.id).label("nb_documents"),
|
||||
)
|
||||
.where(Document.date.between(debut, fin))
|
||||
.group_by(Document.immeuble_id)
|
||||
.subquery()
|
||||
)
|
||||
flux = flux_par(Document.immeuble_id)
|
||||
stock = restant_du_par(Document.immeuble_id)
|
||||
flux = flux_par(Document.immeuble_id, debut, fin)
|
||||
stock = restant_du_par(Document.immeuble_id, debut, fin)
|
||||
|
||||
# Jointure fermee sur l'activite : un immeuble sans compte rendu cette
|
||||
# annee-la n'a rien a montrer et sortirait avec des montants vides.
|
||||
stmt = (
|
||||
select(
|
||||
Immeuble,
|
||||
@@ -371,8 +326,8 @@ async def get_immeubles_shortcuts(
|
||||
flux.c.facture,
|
||||
stock.c.restant_du,
|
||||
)
|
||||
.join(activite, activite.c.immeuble_id == Immeuble.id)
|
||||
.outerjoin(effectifs, effectifs.c.immeuble_id == Immeuble.id)
|
||||
.outerjoin(activite, activite.c.immeuble_id == Immeuble.id)
|
||||
.outerjoin(flux, flux.c.cle == Immeuble.id)
|
||||
.outerjoin(stock, stock.c.cle == Immeuble.id)
|
||||
.order_by(desc(activite.c.nb_documents))
|
||||
|
||||
97
src/plesna_gerance/services/tresorerie.py
Normal file
97
src/plesna_gerance/services/tresorerie.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Ce qui est réellement reversé au propriétaire, et son rapprochement.
|
||||
|
||||
Le compte rendu de gérance se termine par un solde : ce que le gérant verse au
|
||||
propriétaire pour la période (créditeur) ou lui réclame (débiteur). Ce montant
|
||||
est extrait du PDF, il ne se recalcule pas.
|
||||
|
||||
Il se **retrouve** en revanche à partir des lignes, et c'est tout l'intérêt :
|
||||
|
||||
net reversé = encaissé − débit + crédit
|
||||
|
||||
L'encaissé, pas le facturé : un loyer appelé mais impayé n'est pas versé. Le
|
||||
crédit des opérations, souvent nul, porte les régularisations en faveur du
|
||||
propriétaire ; l'oublier décale le rapprochement d'autant.
|
||||
|
||||
Sur la base réelle, cette égalité tombe au centime pour avril, mai et juin 2026,
|
||||
et s'écarte pour février et mars — les deux mois dont l'extraction a par ailleurs
|
||||
des défauts. L'écart entre le net recalculé et le solde annoncé est donc affiché,
|
||||
jamais lissé : c'est le seul contrôle de bout en bout dont on dispose sur la
|
||||
qualité d'une extraction, et un écart qui apparaît est une anomalie à instruire.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from unicodedata import combining, normalize
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from ..database.models import Depense, Document
|
||||
|
||||
#: Un solde créditeur est versé au propriétaire, un solde débiteur lui est
|
||||
#: réclamé. La base stocke les deux formes « créditeur » et « crediteur » selon
|
||||
#: que le parser a lu le PDF ou est retombé sur sa valeur par défaut.
|
||||
SOLDE_CREDITEUR = "crediteur"
|
||||
|
||||
|
||||
def _sans_accents(valeur: str) -> str:
|
||||
return "".join(c for c in normalize("NFD", valeur) if not combining(c))
|
||||
|
||||
|
||||
def est_crediteur(solde_type: str | None) -> bool:
|
||||
"""Le solde est-il en faveur du propriétaire ?"""
|
||||
if not solde_type:
|
||||
return False
|
||||
return _sans_accents(solde_type).lower() == SOLDE_CREDITEUR
|
||||
|
||||
|
||||
def solde_signe(montant: float | None, solde_type: str | None) -> float:
|
||||
"""Solde d'un compte rendu, compté positivement quand il est versé."""
|
||||
montant = montant or 0.0
|
||||
return montant if est_crediteur(solde_type) else -montant
|
||||
|
||||
|
||||
def _borner(stmt, date_debut: date | None, date_fin: date | None):
|
||||
if date_debut is not None:
|
||||
stmt = stmt.where(Document.date >= date_debut)
|
||||
if date_fin is not None:
|
||||
stmt = stmt.where(Document.date <= date_fin)
|
||||
return stmt
|
||||
|
||||
|
||||
def depenses_par(cle, date_debut: date | None = None, date_fin: date | None = None):
|
||||
"""Sous-requête : débit et crédit des opérations, regroupés par `cle`.
|
||||
|
||||
Les dépenses sont des flux, elles se cumulent sans précaution particulière —
|
||||
contrairement aux revenus, aucune ligne ne reporte le mois précédent.
|
||||
"""
|
||||
stmt = (
|
||||
select(
|
||||
cle.label("cle"),
|
||||
func.sum(Depense.debit).label("debit"),
|
||||
func.sum(Depense.credit).label("credit"),
|
||||
)
|
||||
.join(Document, Depense.document_id == Document.id)
|
||||
.group_by(cle)
|
||||
)
|
||||
return _borner(stmt, date_debut, date_fin).subquery()
|
||||
|
||||
|
||||
def somme_soldes_annonces(
|
||||
session, date_debut: date | None = None, date_fin: date | None = None
|
||||
) -> float:
|
||||
"""Somme signée des soldes annoncés par les comptes rendus de la période.
|
||||
|
||||
Les soldes sont signés en Python plutôt qu'en SQL pour que `solde_signe`
|
||||
reste la seule règle : reconnaître un créditeur en base demanderait d'y
|
||||
redire quelles orthographes acceptées, et les deux versions divergeraient.
|
||||
Le volume s'y prête, un compte rendu par immeuble et par mois.
|
||||
"""
|
||||
stmt = select(Document.solde_montant, Document.solde_type)
|
||||
lignes = session.execute(_borner(stmt, date_debut, date_fin)).all()
|
||||
return round(sum(solde_signe(montant, type_) for montant, type_ in lignes), 2)
|
||||
|
||||
|
||||
def net_reverse(
|
||||
encaisse: float | None, debit: float | None, credit: float | None
|
||||
) -> float:
|
||||
"""Ce qui revient au propriétaire sur la période."""
|
||||
return round((encaisse or 0.0) - (debit or 0.0) + (credit or 0.0), 2)
|
||||
@@ -4,31 +4,33 @@ Les deux ecrans calculaient leurs totaux chacun de leur cote, avec des regles
|
||||
differentes : l'accueil lisait le dernier compte rendu, la page Revenus cumulait
|
||||
tout. Ils affichaient donc deux montants d'impayes pour la meme notion. Ces
|
||||
tests verrouillent leur accord.
|
||||
|
||||
L'accueil resume desormais une annee civile entiere. S'y ajoute donc ce que
|
||||
cette bascule engage : la periode couverte suit les donnees et non l'horloge, et
|
||||
le net reverse se rapproche du solde annonce par les comptes rendus.
|
||||
"""
|
||||
|
||||
import copy
|
||||
from datetime import date, timedelta
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from plesna_gerance.database.service import DatabaseService
|
||||
|
||||
|
||||
def _mois_glissant(recul: int) -> str:
|
||||
jour = date.today().replace(day=15)
|
||||
for _ in range(recul):
|
||||
jour = (jour.replace(day=1) - timedelta(days=1)).replace(day=15)
|
||||
return jour.isoformat()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def deux_comptes_rendus(db_session, sample_data):
|
||||
"""Un impaye de 300 ne le mois dernier, reporte et non regle ce mois-ci."""
|
||||
"""Un impaye de 300 ne en janvier, reporte et non regle en fevrier."""
|
||||
service = DatabaseService(db_session)
|
||||
|
||||
premier = copy.deepcopy(sample_data)
|
||||
premier["metadata"]["document"]["reference"] = "M1"
|
||||
premier["metadata"]["document"]["date"] = _mois_glissant(1)
|
||||
premier["metadata"]["document"]["date"] = "2024-01-15"
|
||||
premier["metadata"]["solde"] = {
|
||||
"montant": 450.0,
|
||||
"type": "crediteur",
|
||||
"date_arrete": "2024-01-15",
|
||||
}
|
||||
premier["situation_locataires"][0]["lignes"] = [
|
||||
{
|
||||
"type": "loyer",
|
||||
@@ -43,7 +45,12 @@ def deux_comptes_rendus(db_session, sample_data):
|
||||
|
||||
second = copy.deepcopy(sample_data)
|
||||
second["metadata"]["document"]["reference"] = "M2"
|
||||
second["metadata"]["document"]["date"] = _mois_glissant(0)
|
||||
second["metadata"]["document"]["date"] = "2024-02-15"
|
||||
second["metadata"]["solde"] = {
|
||||
"montant": 750.0,
|
||||
"type": "crediteur",
|
||||
"date_arrete": "2024-02-15",
|
||||
}
|
||||
second["situation_locataires"][0]["lignes"] = [
|
||||
{
|
||||
"type": "solde_anterieur",
|
||||
@@ -66,36 +73,189 @@ def deux_comptes_rendus(db_session, sample_data):
|
||||
return db_session
|
||||
|
||||
|
||||
def test_les_deux_ecrans_annoncent_le_meme_impaye(api_client, deux_comptes_rendus):
|
||||
"""300 dus, vus depuis l'accueil comme depuis la page Revenus."""
|
||||
accueil = api_client.get("/api/dashboard/financial-summary").json()
|
||||
def _resume(api_client, **params):
|
||||
return api_client.get("/api/dashboard/resume-annuel", params=params).json()
|
||||
|
||||
|
||||
def test_les_deux_ecrans_annoncent_le_meme_impaye(api_client, db_session, sample_data):
|
||||
"""300 dus, vus depuis l'accueil comme depuis la page Revenus.
|
||||
|
||||
Le compte rendu est date d'aujourd'hui a dessein : les deux ecrans ne
|
||||
cadrent plus la meme periode — annee civile ici, douze mois glissants
|
||||
la-bas — et seule une donnee recente tombe dans les deux fenetres. Leur
|
||||
accord porte sur la regle, pas sur le cadrage.
|
||||
"""
|
||||
document = copy.deepcopy(sample_data)
|
||||
document["metadata"]["document"]["date"] = date.today().isoformat()
|
||||
document["situation_locataires"][0]["lignes"] = [
|
||||
{
|
||||
"type": "loyer",
|
||||
"periode": {"debut": None, "fin": None},
|
||||
"loyers": 800.0,
|
||||
"total": 800.0,
|
||||
"regles": 500.0,
|
||||
"impayes": 300.0,
|
||||
}
|
||||
]
|
||||
DatabaseService(db_session).save_document(data=document)
|
||||
|
||||
accueil = _resume(api_client)
|
||||
revenus = api_client.get("/api/revenus/summary").json()
|
||||
|
||||
assert accueil["impayes"] == 300.0
|
||||
assert accueil["restant_du"] == 300.0
|
||||
assert revenus["kpis"]["total_impayes"] == 300.0
|
||||
|
||||
|
||||
def test_le_revenu_du_dernier_compte_rendu_exclut_le_report(
|
||||
api_client, deux_comptes_rendus
|
||||
):
|
||||
"""Le mois vaut son loyer de 800, pas 1100 report compris."""
|
||||
accueil = api_client.get("/api/dashboard/financial-summary").json()
|
||||
def test_le_facture_de_l_annee_exclut_le_report(api_client, deux_comptes_rendus):
|
||||
"""L'annee vaut ses deux loyers de 800, pas 1900 report compris."""
|
||||
assert _resume(api_client)["facture"] == 1600.0
|
||||
|
||||
assert accueil["revenus"] == 800.0
|
||||
|
||||
def test_l_encaisse_retient_les_reglements_de_dettes_anciennes(
|
||||
api_client, db_session, sample_data
|
||||
):
|
||||
"""Un vieil impaye rattrape est un encaissement du mois, pas un report."""
|
||||
service = DatabaseService(db_session)
|
||||
document = copy.deepcopy(sample_data)
|
||||
document["metadata"]["document"]["date"] = "2024-03-15"
|
||||
document["situation_locataires"][0]["lignes"] = [
|
||||
{
|
||||
"type": "solde_anterieur",
|
||||
"periode": {"debut": None, "fin": None},
|
||||
"loyers": 300.0,
|
||||
"total": 300.0,
|
||||
"regles": 300.0,
|
||||
"impayes": 0.0,
|
||||
}
|
||||
]
|
||||
service.save_document(data=document)
|
||||
|
||||
resume = _resume(api_client)
|
||||
|
||||
assert resume["facture"] == 0.0
|
||||
assert resume["encaisse"] == 300.0
|
||||
|
||||
|
||||
def test_la_tendance_mensuelle_reste_comparable(api_client, deux_comptes_rendus):
|
||||
"""Chaque mois pese son loyer, sinon le second parait meilleur qu'il n'est."""
|
||||
par_mois = _resume(api_client)["par_mois"]
|
||||
|
||||
assert [point["mois"] for point in par_mois] == ["2024-01", "2024-02"]
|
||||
assert [point["facture"] for point in par_mois] == [800.0, 800.0]
|
||||
|
||||
|
||||
def test_les_raccourcis_immeubles_ne_cumulent_pas_la_dette(
|
||||
api_client, deux_comptes_rendus
|
||||
):
|
||||
"""Le raccourci montre la dette en cours, pas sa somme mois apres mois."""
|
||||
(raccourci,) = api_client.get("/api/dashboard/immeubles-shortcuts").json()
|
||||
(raccourci,) = api_client.get(
|
||||
"/api/dashboard/immeubles-shortcuts", params={"annee": 2024}
|
||||
).json()
|
||||
|
||||
assert raccourci["total_impayes"] == 300.0
|
||||
assert raccourci["total_revenus"] == 1600.0 # 800 + 800, report exclu
|
||||
|
||||
|
||||
def test_la_tendance_mensuelle_reste_comparable(api_client, deux_comptes_rendus):
|
||||
"""Chaque mois pese son loyer, sinon le second parait meilleur qu'il n'est."""
|
||||
tendances = api_client.get("/api/dashboard/monthly-trends").json()
|
||||
def test_le_net_reverse_se_rapproche_du_solde_annonce(api_client, deux_comptes_rendus):
|
||||
"""Encaisse moins depenses doit retomber sur les soldes des comptes rendus.
|
||||
|
||||
assert [point["revenus"] for point in tendances] == [800.0, 800.0]
|
||||
C'est le seul controle de bout en bout sur la qualite d'une extraction :
|
||||
1300 encaisses, 100 de depenses sur les deux documents, contre 450 + 750
|
||||
annonces.
|
||||
"""
|
||||
resume = _resume(api_client)
|
||||
|
||||
assert resume["encaisse"] == 1300.0
|
||||
assert resume["depenses_debit"] == 100.0
|
||||
assert resume["net_reverse"] == 1200.0
|
||||
assert resume["soldes_annonces"] == 1200.0
|
||||
assert resume["ecart_soldes"] == 0.0
|
||||
|
||||
|
||||
def test_un_ecart_de_solde_est_annonce_et_non_lisse(
|
||||
api_client, db_session, sample_data
|
||||
):
|
||||
"""Quand le recalcul et le solde du PDF divergent, l'ecart doit se voir."""
|
||||
document = copy.deepcopy(sample_data)
|
||||
document["metadata"]["document"]["date"] = "2024-01-15"
|
||||
document["metadata"]["solde"] = {
|
||||
"montant": 1000.0,
|
||||
"type": "crediteur",
|
||||
"date_arrete": "2024-01-15",
|
||||
}
|
||||
DatabaseService(db_session).save_document(data=document)
|
||||
|
||||
resume = _resume(api_client)
|
||||
|
||||
# 500 encaisses - 50 de depenses = 450, la ou le document annonce 1000.
|
||||
assert resume["net_reverse"] == 450.0
|
||||
assert resume["soldes_annonces"] == 1000.0
|
||||
assert resume["ecart_soldes"] == -550.0
|
||||
|
||||
|
||||
def test_un_solde_debiteur_se_soustrait(api_client, db_session, sample_data):
|
||||
"""Un mois debiteur est reclame au proprietaire, il ne s'ajoute pas."""
|
||||
document = copy.deepcopy(sample_data)
|
||||
document["metadata"]["document"]["date"] = "2024-01-15"
|
||||
document["metadata"]["solde"] = {
|
||||
"montant": 200.0,
|
||||
"type": "débiteur",
|
||||
"date_arrete": "2024-01-15",
|
||||
}
|
||||
DatabaseService(db_session).save_document(data=document)
|
||||
|
||||
assert _resume(api_client)["soldes_annonces"] == -200.0
|
||||
|
||||
|
||||
def test_l_annee_par_defaut_suit_les_donnees_pas_l_horloge(
|
||||
api_client, deux_comptes_rendus
|
||||
):
|
||||
"""Sans import recent, l'accueil montre la derniere annee pleine, pas du vide."""
|
||||
resume = _resume(api_client)
|
||||
|
||||
assert resume["annee"] == 2024
|
||||
assert resume["annee"] != date.today().year
|
||||
assert resume["annees_disponibles"] == [2024]
|
||||
assert resume["perimetre"]["nb_comptes_rendus"] == 2
|
||||
|
||||
|
||||
def test_une_annee_sans_compte_rendu_reste_lisible(api_client, deux_comptes_rendus):
|
||||
"""Choisir une annee vide donne des zeros, pas une erreur."""
|
||||
resume = _resume(api_client, annee=2023)
|
||||
|
||||
assert resume["annee"] == 2023
|
||||
assert resume["perimetre"]["nb_comptes_rendus"] == 0
|
||||
assert resume["facture"] == 0.0
|
||||
assert resume["restant_du"] == 0.0
|
||||
assert resume["par_mois"] == []
|
||||
|
||||
|
||||
def test_les_annees_ne_se_melangent_pas(api_client, db_session, sample_data):
|
||||
"""Le total d'une annee ne doit rien emprunter a la suivante."""
|
||||
service = DatabaseService(db_session)
|
||||
for reference, jour in (("A", "2024-06-15"), ("B", "2025-06-15")):
|
||||
document = copy.deepcopy(sample_data)
|
||||
document["metadata"]["document"]["reference"] = reference
|
||||
document["metadata"]["document"]["date"] = jour
|
||||
service.save_document(data=document)
|
||||
|
||||
assert _resume(api_client, annee=2024)["facture"] == 500.0
|
||||
assert _resume(api_client, annee=2025)["facture"] == 500.0
|
||||
assert _resume(api_client)["annees_disponibles"] == [2025, 2024]
|
||||
|
||||
|
||||
def test_le_perimetre_ne_compte_que_les_lots_factures(api_client, deux_comptes_rendus):
|
||||
"""La table `lots` retient d'anciennes ecritures de numeros, sans revenu."""
|
||||
assert _resume(api_client)["perimetre"]["nb_lots_factures"] == 1
|
||||
|
||||
|
||||
def test_une_base_vide_ne_casse_pas_l_accueil(api_client):
|
||||
"""Premiere ouverture de l'application : aucun document, aucune erreur."""
|
||||
resume = _resume(api_client)
|
||||
|
||||
assert resume["annees_disponibles"] == []
|
||||
assert resume["annee"] == date.today().year
|
||||
assert resume["facture"] == 0.0
|
||||
assert resume["taux_recouvrement"] == 100.0
|
||||
assert resume["par_mois"] == []
|
||||
assert api_client.get("/api/dashboard/immeubles-shortcuts").json() == []
|
||||
|
||||
Reference in New Issue
Block a user