feat: ouvre le choix de la période sur la fiche d'un lot

Un second sélecteur accompagne celui du lot : tout l'historique, trois
mois, six mois, un an, deux ans, cinq ans. Le défaut reste l'historique
entier — un filtre par défaut cacherait des opérations dès l'arrivée sur
la page, sans que rien ne le signale.

La période part dans l'URL, comme le lot : une fiche filtrée se partage
et se recharge telle qu'on la lisait. Une valeur qui ne correspond à
aucune durée proposée est ignorée et retirée de l'adresse.

Le filtrage est demandé au serveur plutôt que rejoué ici : le taux de
recouvrement et le restant dû sortent de requêtes SQL que le navigateur
ne peut pas refaire, et les recalculer de son côté les ferait diverger de
la chronologie affichée juste dessous.

Ce que la fenêtre écarte reste dit, là où le lecteur pourrait le croire
inexistant :

- un bandeau donne les bornes retenues, précise qu'elles sont calées sur
  le dernier compte rendu du lot et non sur aujourd'hui, compte les lignes
  antérieures exclues et offre de tout réafficher ;
- sous la courbe, le nombre de mois non tracés, et le rappel que le loyer
  en vigueur, sa date d'effet et la comparaison au parc restent lus sur
  tout l'historique — sans quoi « depuis mai 26 » au-dessus d'un graphe
  qui commence en mai passerait pour une contradiction ;
- à côté des noms, le nombre d'occupants antérieurs à la période ;
- le bloc des régularisations hors courbe s'affiche même quand la période
  n'en laisse aucune à lister, pour dire qu'il en existe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 17:26:33 +02:00
parent 005675728d
commit 24420027a5
2 changed files with 140 additions and 22 deletions

View File

@@ -24,6 +24,17 @@
<div class="card-body pt-0"> <div class="card-body pt-0">
<LoyerChart :serie="loyer.serie" :surface="loyer.surface" /> <LoyerChart :serie="loyer.serie" :surface="loyer.surface" />
<!-- La courbe suit la période choisie, les tuiles du dessus non : le dire
ici évite de lire « depuis mai 26 » au-dessus d'un graphe qui
commence en avril et d'y voir une contradiction. -->
<p v-if="loyer.mois_masques" class="form-hint mt-3">
Courbe limitée à la période choisie :
{{ loyer.mois_masques }}
{{ loyer.mois_masques > 1 ? 'mois antérieurs ne sont pas tracés' : 'mois antérieur n\'est pas tracé' }}.
Le loyer en vigueur, sa date d'effet et la comparaison au parc restent lus
sur tout l'historique.
</p>
</div> </div>
<!-- Comparaison au parc : les médianes excluent le lot lui-même, et <!-- Comparaison au parc : les médianes excluent le lot lui-même, et
@@ -68,17 +79,35 @@
</div> </div>
<!-- Ce que la courbe ne peut pas porter reste visible : sans cette liste, <!-- 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. --> 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"> Le bloc s'affiche aussi quand la période n'en laisse aucune, pour dire
qu'il en existe. -->
<div v-if="loyer.hors_courbe.length || loyer.hors_courbe_masquees" class="card-body pt-0">
<!-- Le compteur disparaît quand la période ne laisse rien à lister : un
« · 0 » se lirait comme une absence, alors que le texte annonce
justement des lignes mises de côté. -->
<h3 class="text-sm font-medium text-white mb-2"> <h3 class="text-sm font-medium text-white mb-2">
Hors de la courbe · {{ loyer.hors_courbe.length }} Hors de la courbe<template v-if="loyer.hors_courbe.length"> · {{ loyer.hors_courbe.length }}</template>
</h3> </h3>
<p class="form-hint mb-3"> <p class="form-hint mb-3">
<template v-if="loyer.hors_courbe.length">
Régularisations à cheval sur plusieurs mois sans en couvrir aucun entièrement. 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 ; Les rattacher à un mois inventerait un loyer que le compte rendu ne porte pas ;
elles figurent dans la chronologie ci-dessous. elles figurent dans la chronologie ci-dessous.
<template v-if="loyer.hors_courbe_masquees">
{{ loyer.hors_courbe_masquees }}
{{ loyer.hors_courbe_masquees > 1 ? 'autres sont antérieures' : 'autre est antérieure' }}
à la période choisie.
</template>
</template>
<template v-else>
{{ loyer.hors_courbe_masquees }}
{{ loyer.hors_courbe_masquees > 1 ? 'régularisations antérieures' : 'régularisation antérieure' }}
à la période choisie : élargir la période
{{ loyer.hors_courbe_masquees > 1 ? 'les ramène' : 'la ramène' }} ici.
</template>
</p> </p>
<table class="table"> <table v-if="loyer.hors_courbe.length" class="table">
<thead> <thead>
<tr> <tr>
<th>Période</th> <th>Période</th>

View File

@@ -9,12 +9,20 @@
</p> </p>
</div> </div>
<div class="flex flex-wrap items-center gap-3">
<select v-model="lotChoisi" class="input w-64"> <select v-model="lotChoisi" class="input w-64">
<option v-for="lot in lots" :key="lot.id" :value="lot.id"> <option v-for="lot in lots" :key="lot.id" :value="lot.id">
{{ lot.numero }} {{ lot.immeuble_denomination || lot.immeuble_code }} {{ lot.numero }} {{ lot.immeuble_denomination || lot.immeuble_code }}
<template v-if="lot.type_effectif"> · {{ lot.type_effectif }}</template> <template v-if="lot.type_effectif"> · {{ lot.type_effectif }}</template>
</option> </option>
</select> </select>
<select v-model="moisChoisis" class="input w-44">
<option v-for="periode in PERIODES" :key="periode.libelle" :value="periode.mois">
{{ periode.libelle }}
</option>
</select>
</div>
</div> </div>
<div v-if="chargement" class="empty-state"> <div v-if="chargement" class="empty-state">
@@ -58,9 +66,53 @@
:key="nom" :key="nom"
class="badge badge-neutral ml-2" class="badge badge-neutral ml-2"
>{{ nom }}</span> >{{ nom }}</span>
<!-- Aucune date d'entrée ni de sortie n'est extraite : un locataire
appartient à la période parce qu'un compte rendu de la période
le porte. Ceux d'avant sont comptés, jamais effacés. -->
<span v-if="analyse.identite.locataires_masques" class="ml-2 text-xs text-gray-500">
+ {{ analyse.identite.locataires_masques }}
{{ analyse.identite.locataires_masques > 1 ? 'occupants antérieurs' : 'occupant antérieur' }}
à la période
</span>
</div> </div>
</div> </div>
<!-- Ce que la fenêtre retient, et surtout ce qu'elle laisse dehors :
un filtre muet ferait passer un lot amputé pour un lot calme. -->
<div v-if="fenetreActive" class="card card-body border-l-2 border-blue-500/50">
<p class="text-sm text-gray-300">
Du <span class="text-white">{{ formatDate(analyse.periode.debut) }}</span>
au <span class="text-white">{{ formatDate(analyse.periode.fin) }}</span>
<span class="text-gray-500">
· calé sur le dernier compte rendu du lot, pas sur aujourd'hui
</span>
</p>
<p class="form-hint mt-1">
<template v-if="analyse.periode.lignes_masquees">
{{ analyse.periode.lignes_masquees }}
{{ analyse.periode.lignes_masquees > 1 ? 'lignes antérieures sont exclues' : 'ligne antérieure est exclue' }}
des chiffres, de la chronologie et des intervenants ; la courbe du loyer
suit la même période.
<button class="text-blue-400 hover:text-blue-300 underline" @click="moisChoisis = null">
Tout afficher
</button>
</template>
<template v-else>
Aucune ligne du lot ne tombe hors de cette fenêtre : les chiffres sont
ceux de tout son historique.
</template>
</p>
</div>
<!-- Une fenêtre demandée sur un lot dont aucun compte rendu ne parle
n'a rien sur quoi se caler : le dire vaut mieux qu'afficher des
totaux à zéro sans raison apparente. -->
<p v-else-if="moisChoisis !== null" class="form-hint">
Aucun compte rendu ne porte de ligne sur ce lot : il n'y a pas de période à
borner, et la fiche reste entière.
</p>
<!-- Chiffres --> <!-- Chiffres -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4"> <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div v-for="tuile in tuiles" :key="tuile.libelle" class="card card-body"> <div v-for="tuile in tuiles" :key="tuile.libelle" class="card card-body">
@@ -244,6 +296,24 @@ const chargement = ref(true)
const depensesSeules = ref(false) const depensesSeules = ref(false)
const detailsOuverts = reactive({}) const detailsOuverts = reactive({})
// `null` en tête : la fiche s'ouvre sur tout l'historique, et c'est
// l'utilisateur qui décide de restreindre. Les durées montent jusqu'à cinq ans,
// au-delà desquels « tout » revient au même sur un parc suivi depuis 2024.
const PERIODES = [
{ mois: null, libelle: "Tout l'historique" },
{ mois: 3, libelle: '3 derniers mois' },
{ mois: 6, libelle: '6 derniers mois' },
{ mois: 12, libelle: 'Dernière année' },
{ mois: 24, libelle: '2 dernières années' },
{ mois: 60, libelle: '5 dernières années' }
]
const moisChoisis = ref(null)
// Une fenêtre n'est active que si le serveur a pu la caler : sur un lot sans
// aucune ligne, il n'y a pas de dernier compte rendu et donc pas de bornes.
const fenetreActive = computed(() => analyse.value?.periode?.debut != null)
const CHAMPS_IDENTITE = [ const CHAMPS_IDENTITE = [
{ cle: 'type_effectif', libelle: 'Type' }, { cle: 'type_effectif', libelle: 'Type' },
{ cle: 'surface', libelle: 'Surface', unite: ' m²' }, { cle: 'surface', libelle: 'Surface', unite: ' m²' },
@@ -357,39 +427,58 @@ async function chargerLots() {
lots.value = await response.json() lots.value = await response.json()
} }
async function chargerAnalyse(lotId) { async function chargerAnalyse(lotId, mois) {
analyse.value = null analyse.value = null
// Les dépliages appartiennent au lot affiché : gardés, une entreprise // Les dépliages appartiennent au lot affiché : gardés, une entreprise
// présente sur deux lots (PPR par exemple) arriverait déjà ouverte. // présente sur deux lots (PPR par exemple) arriverait déjà ouverte.
for (const fournisseur of Object.keys(detailsOuverts)) delete detailsOuverts[fournisseur] for (const fournisseur of Object.keys(detailsOuverts)) delete detailsOuverts[fournisseur]
const response = await fetch(`${API}/api/lots/${lotId}/analyse`) // Le filtre est appliqué côté serveur : les chiffres (taux de recouvrement,
// restant dû) sortent de requêtes SQL que le navigateur ne peut pas rejouer,
// et les recalculer ici les ferait diverger de la chronologie.
const parametres = mois == null ? '' : `?mois=${mois}`
const response = await fetch(`${API}/api/lots/${lotId}/analyse${parametres}`)
if (response.ok) analyse.value = await response.json() if (response.ok) analyse.value = await response.json()
} }
// L'URL porte le lot : une fiche se partage et se recharge sans repasser par // L'URL porte le lot et la période : une fiche se partage et se recharge telle
// le sélecteur. // qu'on la lisait, filtre compris.
watch(lotChoisi, (lotId) => { watch([lotChoisi, moisChoisis], ([lotId, mois]) => {
if (lotId == null) return if (lotId == null) return
if (String(lotId) !== route.params.id) router.replace(`/lots/${lotId}`)
chargerAnalyse(lotId) const query = mois == null ? {} : { mois: String(mois) }
if (String(lotId) !== route.params.id || query.mois !== route.query.mois) {
router.replace({ path: `/lots/${lotId}`, query })
}
chargerAnalyse(lotId, mois)
}) })
/** Période lue dans l'URL, ramenée à une des durées proposées. */
function moisDeLUrl() {
const demande = Number(route.query.mois)
return PERIODES.some((periode) => periode.mois === demande) ? demande : null
}
// Passer d'un lot à l'autre reste la même route : Vue réutilise le composant // Passer d'un lot à l'autre reste la même route : Vue réutilise le composant
// sans le remonter, et `onMounted` ne rejoue pas. Sans ce suivi, un clic dans // sans le remonter, et `onMounted` ne rejoue pas. Sans ce suivi, un clic dans
// le nuage du parc changerait l'adresse en laissant la fiche du lot précédent // le nuage du parc changerait l'adresse en laissant la fiche du lot précédent
// à l'écran — l'écart le plus trompeur qui soit entre l'URL et ce qu'on lit. // à l'écran — l'écart le plus trompeur qui soit entre l'URL et ce qu'on lit.
// Le retour arrière du navigateur passe par le même chemin, période comprise.
watch( watch(
() => route.params.id, [() => route.params.id, () => route.query.mois],
(id) => { ([id]) => {
const demande = Number(id) const demande = Number(id)
if (Number.isFinite(demande) && demande !== lotChoisi.value) lotChoisi.value = demande if (Number.isFinite(demande) && demande !== lotChoisi.value) lotChoisi.value = demande
moisChoisis.value = moisDeLUrl()
} }
) )
onMounted(async () => { onMounted(async () => {
try { try {
await chargerLots() await chargerLots()
// Avant le lot : les deux changent dans le même tick, le watcher ne part
// qu'une fois et la première requête porte déjà la période de l'URL.
moisChoisis.value = moisDeLUrl()
const demande = Number(route.params.id) const demande = Number(route.params.id)
lotChoisi.value = lots.value.some((lot) => lot.id === demande) lotChoisi.value = lots.value.some((lot) => lot.id === demande)
? demande ? demande