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>

View File

@@ -0,0 +1,252 @@
<template>
<div class="card">
<div class="card-header">
<h2 class="card-title">Loyer</h2>
<router-link
v-if="!aUneSurface"
to="/logements"
class="btn btn-secondary btn-sm"
>
Saisir la surface
</router-link>
</div>
<!-- Le loyer en vigueur, son niveau au , sa dernière révision. Chaque
case garde sa place même vide : un tableau qui perd une colonne selon
le lot empêche de comparer deux fiches. -->
<div class="card-body grid grid-cols-2 lg:grid-cols-4 gap-4">
<div v-for="tuile in tuiles" :key="tuile.libelle">
<div class="text-xs text-gray-400 uppercase tracking-wide">{{ tuile.libelle }}</div>
<div class="text-2xl font-bold" :class="tuile.classe">{{ tuile.valeur }}</div>
<div v-if="tuile.detail" class="text-xs text-gray-500 mt-1">{{ tuile.detail }}</div>
</div>
</div>
<div class="card-body pt-0">
<LoyerChart :serie="loyer.serie" :surface="loyer.surface" />
</div>
<!-- Comparaison au parc : les médianes excluent le lot lui-même, et
l'effectif accompagne toujours le chiffre. -->
<div v-if="loyer.parc" class="card-body pt-0">
<h3 class="text-sm font-medium text-white mb-3">
Au mètre carré, face au parc
<span class="text-gray-500 font-normal">· {{ formatMoisAnnee(loyer.parc.mois) }}</span>
</h3>
<div v-if="!aUneSurface" class="form-hint">
Sans surface saisie sur ce lot, il n'y a rien à comparer. La fiche se complète
depuis
<router-link
to="/logements"
class="text-blue-400 hover:text-blue-300 transition-colors underline"
>Logements</router-link>.
</div>
<template v-else>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div v-for="repere in reperes" :key="repere.libelle" class="bg-gray-950 rounded p-3">
<div class="text-xs text-gray-400 uppercase tracking-wide">{{ repere.libelle }}</div>
<div class="text-lg font-semibold" :class="repere.classe">{{ repere.valeur }}</div>
<div class="text-xs text-gray-500 mt-1">{{ repere.detail }}</div>
</div>
</div>
<p v-if="loyer.parc.sans_surface" class="form-hint mt-3">
{{ loyer.parc.sans_surface }}
{{ loyer.parc.sans_surface > 1 ? 'lots loués ce mois-là n\'ont' : 'lot loué ce mois-là n\'a' }}
pas de surface sur sa fiche : {{ loyer.parc.sans_surface > 1 ? 'ils restent' : 'il reste' }}
hors de ces médianes.
</p>
<!-- Les médianes ci-dessus mêlent toutes les surfaces ; le nuage rend
la comparaison à taille comparable, que le chiffre seul écrase. -->
<div class="mt-6">
<ParcScatter :nuage="loyer.parc.nuage" />
</div>
</template>
</div>
<!-- Ce que la courbe ne peut pas porter reste visible : sans cette liste,
des montants disparaîtraient de la page sans que rien ne le signale. -->
<div v-if="loyer.hors_courbe.length" class="card-body pt-0">
<h3 class="text-sm font-medium text-white mb-2">
Hors de la courbe · {{ loyer.hors_courbe.length }}
</h3>
<p class="form-hint mb-3">
Régularisations à cheval sur plusieurs mois sans en couvrir aucun entièrement.
Les rattacher à un mois inventerait un loyer que le compte rendu ne porte pas ;
elles figurent dans la chronologie ci-dessous.
</p>
<table class="table">
<thead>
<tr>
<th>Période</th>
<th class="text-right">Montant</th>
</tr>
</thead>
<tbody>
<tr v-for="(ligne, index) in loyer.hors_courbe" :key="index">
<td class="text-gray-300">
{{ formatDate(ligne.periode_debut) }} {{ formatDate(ligne.periode_fin) }}
</td>
<td class="text-right" :class="ligne.montant < 0 ? 'text-red-400' : 'text-gray-300'">
{{ formatMontantPrecis(ligne.montant) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import LoyerChart from './LoyerChart.vue'
import ParcScatter from './ParcScatter.vue'
import {
formatDate,
formatMoisAnnee,
formatMontant,
formatMontantPrecis
} from '../../utils/format'
const props = defineProps({
loyer: { type: Object, required: true }
})
const aUneSurface = computed(() => props.loyer.surface != null && props.loyer.surface > 0)
/** "12,14 €/m²", ou le tiret des valeurs qu'on n'a pas. */
function formatM2(valeur) {
if (valeur == null) return '—'
return `${valeur.toFixed(2).replace('.', ',')} €/m²`
}
const tuiles = computed(() => {
const vigueur = props.loyer.en_vigueur
if (!vigueur) {
return [
{ libelle: 'Loyer', valeur: '—', classe: 'text-gray-500', detail: 'aucun loyer facturé' },
{ libelle: 'Au m²', valeur: '—', classe: 'text-gray-500', detail: surfaceDetail() },
{ libelle: 'Dernière révision', valeur: '—', classe: 'text-gray-500' },
{ libelle: 'Surface', valeur: surfaceValeur(), classe: 'text-gray-300' }
]
}
return [
{
libelle: vigueur.toujours_loue ? 'Loyer en vigueur' : 'Dernier loyer connu',
valeur: formatMontant(vigueur.loyer),
classe: vigueur.toujours_loue ? 'text-green-400' : 'text-gray-400',
// Un lot sorti de la gestion garde un loyer affiché : sans cette mention,
// il se lirait comme une recette courante.
detail: vigueur.toujours_loue
? `depuis ${formatMoisAnnee(vigueur.depuis)}`
: `arrêté après ${formatMoisAnnee(vigueur.mois)}`
},
{
libelle: 'Au m²',
valeur: formatM2(vigueur.loyer_m2),
classe: vigueur.loyer_m2 == null ? 'text-gray-500' : 'text-amber-400',
detail: surfaceDetail()
},
{
libelle: 'Dernière révision',
valeur: vigueur.variation_pct == null ? '—' : formatPourcent(vigueur.variation_pct),
classe: classeVariation(vigueur.variation_pct),
detail:
vigueur.precedent == null
? 'aucune depuis le premier compte rendu'
: `${formatMontant(vigueur.precedent)}${formatMontant(vigueur.loyer)} en ${formatMoisAnnee(vigueur.depuis)}`
},
{
libelle: 'Surface',
valeur: surfaceValeur(),
classe: aUneSurface.value ? 'text-gray-300' : 'text-gray-600',
detail: aUneSurface.value ? 'fiche du logement' : 'à saisir dans Logements'
}
]
})
function surfaceValeur() {
return aUneSurface.value ? `${props.loyer.surface}` : '—'
}
function surfaceDetail() {
return aUneSurface.value ? 'hors charges' : 'surface manquante'
}
function formatPourcent(valeur) {
const signe = valeur > 0 ? '+' : ''
return `${signe}${valeur.toFixed(2).replace('.', ',')} %`
}
function classeVariation(valeur) {
if (valeur == null) return 'text-gray-500'
if (valeur > 0) return 'text-green-400'
if (valeur < 0) return 'text-red-400'
return 'text-gray-300'
}
/**
* Écart du lot à une médiane, en pourcentage.
*
* L'écart dit ce que la médiane seule ne dit pas : savoir que le parc est à
* 13,52 €/m² n'apprend rien tant qu'on n'a pas fait la soustraction.
*/
function ecartA(mediane) {
const valeur = props.loyer.parc?.loyer_m2
if (valeur == null || !mediane) return null
return Math.round(((valeur - mediane) / mediane) * 1000) / 10
}
/** Effectif trop faible pour qu'une médiane décrive autre chose que le hasard. */
function effectifFragile(nombre) {
return nombre > 0 && nombre < (props.loyer.parc?.effectif_faible ?? 3)
}
function detailMediane(nombre) {
if (!nombre) return 'aucun lot comparable'
const lots = `${nombre} lot${nombre > 1 ? 's' : ''}`
return effectifFragile(nombre) ? `${lots} — trop peu pour conclure` : lots
}
const reperes = computed(() => {
const parc = props.loyer.parc
const ecartImmeuble = ecartA(parc.mediane_immeuble)
const ecartType = ecartA(parc.mediane_type)
return [
{
libelle: 'Ce lot',
valeur: formatM2(parc.loyer_m2),
classe: 'text-amber-400',
detail: 'loyer hors charges au m²'
},
{
libelle: 'Médiane de l\'immeuble',
valeur: formatM2(parc.mediane_immeuble),
classe: effectifFragile(parc.nb_immeuble) ? 'text-gray-500' : 'text-gray-300',
detail:
ecartImmeuble == null
? detailMediane(parc.nb_immeuble)
: `${formatPourcent(ecartImmeuble)} · ${detailMediane(parc.nb_immeuble)}`
},
{
// Le type vient de la fiche ou du PDF et n'a pas de forme grammaticale
// fixe (« Studio », « Appartement T2 », « Loc. Commercial ») : le poser
// après un point médian évite d'accorder un article sur une valeur libre.
libelle: parc.type_compare ? `Médiane · ${parc.type_compare}` : 'Médiane du même type',
valeur: formatM2(parc.mediane_type),
classe: effectifFragile(parc.nb_type) ? 'text-gray-500' : 'text-gray-300',
detail: parc.type_compare
? ecartType == null
? detailMediane(parc.nb_type)
: `${formatPourcent(ecartType)} · ${detailMediane(parc.nb_type)}`
: 'type du lot non renseigné'
}
]
})
</script>

View File

@@ -0,0 +1,167 @@
<template>
<div>
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
<h3 class="text-sm font-medium text-white">
Le parc par surface
<span class="text-gray-500 font-normal">· {{ nuage.length }} lots comparés</span>
</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-full bg-amber-400"></span>
<span class="text-gray-400">Ce lot</span>
</span>
<span class="flex items-center gap-1">
<span class="w-3 h-3 rounded-full bg-slate-400"></span>
<span class="text-gray-400">Autres lots</span>
</span>
<span v-if="aDuCommercial" class="flex items-center gap-1">
<span class="text-slate-400 leading-none"></span>
<span class="text-gray-400">Local commercial</span>
</span>
</div>
</div>
<!-- Le loyer au décroît avec la surface : sans cette phrase, un lecteur
pourrait lire une pente naturelle du marché comme une anomalie de
gestion. -->
<p class="form-hint mb-3">
Un petit logement se loue plus cher au mètre carré qu'un grand. Ce qui se lit ici
n'est donc pas la hauteur du point seule, mais sa position par rapport aux lots
de surface voisine.
</p>
<div v-if="nuage.length < 2" class="h-64 flex items-center justify-center text-gray-500 text-sm">
Pas encore assez de surfaces saisies pour situer ce lot dans le parc.
</div>
<div v-else class="h-72">
<Scatter :data="donneesGraphe" :options="options" />
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { Scatter } from 'vue-chartjs'
import {
Chart as ChartJS,
LinearScale,
PointElement,
ScatterController,
Tooltip
} from 'chart.js'
ChartJS.register(LinearScale, PointElement, ScatterController, Tooltip)
const props = defineProps({
nuage: { type: Array, required: true, default: () => [] }
})
const router = useRouter()
/**
* Un local commercial ne se loue pas selon la même logique qu'un logement.
* Le garder dans le nuage — le parc en compte — mais le marquer, pour qu'il ne
* soit pas lu comme un comparable direct.
*/
function estCommercial(point) {
return (point.type_lot ?? '').toLowerCase().includes('commercial')
}
const aDuCommercial = computed(() => props.nuage.some(estCommercial))
const autres = computed(() => props.nuage.filter((point) => !point.est_ce_lot))
const courant = computed(() => props.nuage.filter((point) => point.est_ce_lot))
/** Chart.js n'accepte que x/y ; le reste voyage pour l'infobulle et le clic. */
function versPoints(points) {
return points.map((point) => ({ x: point.surface, y: point.loyer_m2, lot: point }))
}
const donneesGraphe = computed(() => ({
datasets: [
{
label: 'Autres lots',
data: versPoints(autres.value),
backgroundColor: 'rgba(148, 163, 184, 0.75)',
pointRadius: 6,
pointHoverRadius: 8,
pointStyle: autres.value.map((point) => (estCommercial(point) ? 'triangle' : 'circle'))
},
{
label: 'Ce lot',
data: versPoints(courant.value),
backgroundColor: 'rgb(251, 191, 36)',
borderColor: 'rgb(253, 230, 138)',
borderWidth: 2,
pointRadius: 9,
pointHoverRadius: 11,
pointStyle: courant.value.map((point) => (estCommercial(point) ? 'triangle' : 'circle'))
}
]
}))
const options = computed(() => ({
responsive: true,
maintainAspectRatio: false,
// Un lot voisin repéré dans le nuage doit pouvoir s'ouvrir : sans cela, il
// faudrait relever son numéro puis le chercher dans le sélecteur.
onClick: (evenement, elements) => {
const touche = elements[0]
if (!touche) return
const point = donneesGraphe.value.datasets[touche.datasetIndex].data[touche.index]
if (point?.lot && !point.lot.est_ce_lot) router.push(`/lots/${point.lot.lot_id}`)
},
onHover: (evenement, elements) => {
evenement.native.target.style.cursor = elements.length ? 'pointer' : 'default'
},
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: {
title: (contextes) => {
const lot = contextes[0].raw.lot
return `Lot ${lot.numero}${lot.immeuble_code ? ` · ${lot.immeuble_code}` : ''}`
},
label: (contexte) => {
const lot = contexte.raw.lot
const lignes = [
`${lot.surface} m² · ${lot.loyer_m2.toFixed(2).replace('.', ',')} €/m²`
]
if (lot.type_lot) lignes.push(lot.type_lot)
if (!lot.est_ce_lot) lignes.push('Cliquer pour ouvrir sa fiche')
return lignes
}
}
}
},
scales: {
x: {
type: 'linear',
title: { display: true, text: 'Surface (m²)', color: 'rgb(156, 163, 175)' },
// L'axe part de zéro : tronquer l'origine étirerait les écarts de
// surface et ferait paraître deux lots voisins bien plus éloignés.
beginAtZero: true,
grid: { color: 'rgb(55, 65, 81)' },
ticks: { color: 'rgb(156, 163, 175)', font: { size: 11 } }
},
y: {
title: { display: true, text: '€/m²', color: 'rgb(156, 163, 175)' },
beginAtZero: true,
grid: { color: 'rgb(55, 65, 81)' },
ticks: {
color: 'rgb(156, 163, 175)',
font: { size: 11 },
callback: (valeur) => `${valeur}`
}
}
}
}))
</script>

View File

@@ -79,6 +79,10 @@
quelle part revient à quel lot.
</p>
<!-- Le loyer sur un axe de temps : c'est là que se voient une révision,
une vacance ou un décrochage, que les totaux du dessus écrasent. -->
<LoyerLot :loyer="analyse.loyer" />
<!-- Chronologie -->
<div class="card">
<div class="card-header">
@@ -225,6 +229,7 @@
<script setup>
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import LoyerLot from '../components/lot/LoyerLot.vue'
import { formatDate, formatMontant, formatMontantPrecis } from '../utils/format'
const API = import.meta.env.VITE_API_URL || ''

View File

@@ -54,3 +54,15 @@ export function formatMois(chaine) {
const [, mois] = chaine.split('-')
return MOIS_COURTS[parseInt(mois, 10) - 1] ?? chaine
}
/**
* "2026-06" → "juin 26", pour les séries qui traversent plusieurs années.
*
* `formatMois` suffit quand un titre porte déjà l'année ; sur trois ans de
* comptes rendus, deux mois de juin se confondraient.
*/
export function formatMoisAnnee(chaine) {
if (!chaine) return ''
const [annee, mois] = chaine.split('-')
return `${MOIS_COURTS[parseInt(mois, 10) - 1] ?? mois} ${annee.slice(2)}`
}