feat: montre sur la fiche le loyer mois par mois et sa place dans le parc

Un bloc « Loyer » s'intercale entre les totaux et la chronologie. Il
porte le loyer en vigueur, sa date d'effet, le prix au mètre carré et la
dernière révision, puis deux graphiques.

Le premier suit le loyer mois par mois, charges empilées et prix au m²
sur son propre axe. Un mois vacant y reste un blanc, jamais une barre au
sol ; un mois de transition prend une couleur distincte et l'infobulle
dit lequel des deux on regarde. Ce que la courbe ne peut pas porter — une
régularisation à cheval sur plusieurs mois sans en couvrir aucun — est
listé dessous plutôt que rattaché de force à un mois.

Le second situe le lot dans le parc, surface en abscisse. Il corrige une
lecture que les médianes rendent fausse : le loyer au m² décroît
fortement avec la taille, si bien qu'un studio de 21 m² affiche +60 %
au-dessus de la médiane de son immeuble sans rien devoir à sa gestion.
Ce qui se lit n'est donc pas la hauteur d'un point, mais sa position
parmi les lots de surface voisine — et une phrase le dit au-dessus du
graphe. Les autres lots s'ouvrent d'un clic ; les locaux commerciaux
gardent leur place mais changent de forme, leur prix ne suivant pas la
même logique.

Sans surface saisie, chaque case reste vide et renvoie vers Logements :
la fiche montre le trou au lieu de le combler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 22:39:07 +02:00
parent b1ff0812c1
commit 288b67c26f
5 changed files with 632 additions and 0 deletions

View File

@@ -0,0 +1,196 @@
<template>
<div>
<div class="flex items-center justify-between mb-4 flex-wrap gap-2">
<h3 class="text-sm font-medium text-white">Loyer mois par mois</h3>
<div class="flex items-center gap-4 text-xs flex-wrap">
<span class="flex items-center gap-1">
<span class="w-3 h-3 rounded bg-green-400"></span>
<span class="text-gray-400">Loyer hors charges</span>
</span>
<span class="flex items-center gap-1">
<span class="w-3 h-3 rounded bg-slate-500"></span>
<span class="text-gray-400">Charges</span>
</span>
<span v-if="aDesProratas" class="flex items-center gap-1">
<span class="w-3 h-3 rounded bg-violet-400"></span>
<span class="text-gray-400">Prorata</span>
</span>
<span v-if="aUneSurface" class="flex items-center gap-1">
<span class="w-3 h-1.5 rounded bg-amber-400"></span>
<span class="text-gray-400">/</span>
</span>
</div>
</div>
<div v-if="!serie.length" class="h-64 flex items-center justify-center text-gray-500">
Aucun loyer facturé sur ce lot.
</div>
<div v-else class="h-72">
<Bar :data="donneesGraphe" :options="options" />
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarController,
BarElement,
LineController,
LineElement,
PointElement,
Tooltip,
Legend
} from 'chart.js'
import { formatMoisAnnee } from '../../utils/format'
// `LineController` en plus des éléments : la courbe du €/m² partage le graphe
// des barres, et Chart.js refuse un type de dataset dont le contrôleur n'a pas
// été enregistré — les autres graphes de l'application n'en mélangent aucun.
ChartJS.register(
CategoryScale,
LinearScale,
BarController,
BarElement,
LineController,
LineElement,
PointElement,
Tooltip,
Legend
)
const props = defineProps({
serie: { type: Array, required: true, default: () => [] },
surface: { type: Number, default: null }
})
const aUneSurface = computed(() => props.surface != null && props.surface > 0)
const aDesProratas = computed(() => props.serie.some((point) => point.prorata != null))
const EUROS = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' })
// Les mois vides restent `null` et non 0 : Chart.js laisse alors un blanc, qui
// est la lecture juste d'une vacance. Un zéro dessinerait une barre au sol,
// c'est-à-dire un loyer nul — ce qu'aucun compte rendu ne dit.
const donneesGraphe = computed(() => {
const jeux = [
{
label: 'Loyer',
data: props.serie.map((point) => point.loyer),
backgroundColor: 'rgba(74, 222, 128, 0.8)',
borderRadius: 3,
stack: 'facture',
order: 3
},
{
label: 'Charges',
data: props.serie.map((point) => point.charges),
backgroundColor: 'rgba(100, 116, 139, 0.7)',
borderRadius: 3,
stack: 'facture',
order: 3
}
]
if (aDesProratas.value) {
jeux.push({
label: 'Prorata',
data: props.serie.map((point) => point.prorata),
backgroundColor: 'rgba(167, 139, 250, 0.85)',
borderRadius: 3,
stack: 'facture',
order: 3
})
}
// Le loyer au m² se lit sur son propre axe : mis à la même échelle que des
// centaines d'euros, sa courbe serait collée au zéro.
if (aUneSurface.value) {
jeux.push({
type: 'line',
label: '€/m²',
data: props.serie.map((point) => point.loyer_m2),
yAxisID: 'y1',
borderColor: 'rgb(251, 191, 36)',
backgroundColor: 'rgb(251, 191, 36)',
borderWidth: 2,
pointRadius: 2,
tension: 0,
spanGaps: false,
order: 1
})
}
return { labels: props.serie.map((point) => formatMoisAnnee(point.mois)), datasets: jeux }
})
const options = computed(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: 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(156, 163, 175)',
padding: 12,
callbacks: {
label: (contexte) => {
if (contexte.raw == null) return null
const valeur =
contexte.dataset.label === '€/m²'
? `${contexte.raw.toFixed(2).replace('.', ',')} €/m²`
: EUROS.format(contexte.raw)
return `${contexte.dataset.label} : ${valeur}`
},
// Un mois sans loyer plein se lit différemment selon qu'il est vacant
// ou en changement de locataire : le dire dans l'infobulle évite de
// laisser le lecteur deviner devant un trou.
afterBody: (contextes) => {
const point = props.serie[contextes[0].dataIndex]
if (!point) return null
const notes = []
if (point.en_transition) notes.push('Changement de locataire')
else if (point.loyer == null) notes.push('Aucun loyer facturé')
if (point.reparti) notes.push('Réparti depuis une facturation pluri-mensuelle')
return notes.length ? notes : null
}
}
}
},
scales: {
x: {
stacked: true,
grid: { display: false },
ticks: { color: 'rgb(156, 163, 175)', font: { size: 10 }, maxRotation: 0 }
},
y: {
stacked: true,
grid: { color: 'rgb(55, 65, 81)' },
ticks: {
color: 'rgb(156, 163, 175)',
font: { size: 11 },
callback: (valeur) => (valeur >= 1000 ? `${(valeur / 1000).toFixed(1)}k` : valeur)
}
},
y1: {
display: aUneSurface.value,
position: 'right',
grid: { display: false },
ticks: {
color: 'rgb(251, 191, 36)',
font: { size: 11 },
callback: (valeur) => `${valeur}`
}
}
}
}))
</script>