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:
@@ -24,6 +24,17 @@
|
||||
|
||||
<div class="card-body pt-0">
|
||||
<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>
|
||||
|
||||
<!-- Comparaison au parc : les médianes excluent le lot lui-même, et
|
||||
@@ -68,17 +79,35 @@
|
||||
</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">
|
||||
des montants disparaîtraient de la page sans que rien ne le signale.
|
||||
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">
|
||||
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>
|
||||
<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.
|
||||
Les rattacher à un mois inventerait un loyer que le compte rendu ne porte pas ;
|
||||
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>
|
||||
<table class="table">
|
||||
<table v-if="loyer.hors_courbe.length" class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Période</th>
|
||||
|
||||
@@ -9,12 +9,20 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<select v-model="lotChoisi" class="input w-64">
|
||||
<option v-for="lot in lots" :key="lot.id" :value="lot.id">
|
||||
{{ lot.numero }} — {{ lot.immeuble_denomination || lot.immeuble_code }}
|
||||
<template v-if="lot.type_effectif"> · {{ lot.type_effectif }}</template>
|
||||
</option>
|
||||
</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 v-if="chargement" class="empty-state">
|
||||
@@ -58,9 +66,53 @@
|
||||
:key="nom"
|
||||
class="badge badge-neutral ml-2"
|
||||
>{{ 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>
|
||||
|
||||
<!-- 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 -->
|
||||
<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">
|
||||
@@ -244,6 +296,24 @@ const chargement = ref(true)
|
||||
const depensesSeules = ref(false)
|
||||
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 = [
|
||||
{ cle: 'type_effectif', libelle: 'Type' },
|
||||
{ cle: 'surface', libelle: 'Surface', unite: ' m²' },
|
||||
@@ -357,39 +427,58 @@ async function chargerLots() {
|
||||
lots.value = await response.json()
|
||||
}
|
||||
|
||||
async function chargerAnalyse(lotId) {
|
||||
async function chargerAnalyse(lotId, mois) {
|
||||
analyse.value = null
|
||||
// Les dépliages appartiennent au lot affiché : gardés, une entreprise
|
||||
// présente sur deux lots (PPR par exemple) arriverait déjà ouverte.
|
||||
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()
|
||||
}
|
||||
|
||||
// L'URL porte le lot : une fiche se partage et se recharge sans repasser par
|
||||
// le sélecteur.
|
||||
watch(lotChoisi, (lotId) => {
|
||||
// L'URL porte le lot et la période : une fiche se partage et se recharge telle
|
||||
// qu'on la lisait, filtre compris.
|
||||
watch([lotChoisi, moisChoisis], ([lotId, mois]) => {
|
||||
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
|
||||
// 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
|
||||
// à 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(
|
||||
() => route.params.id,
|
||||
(id) => {
|
||||
[() => route.params.id, () => route.query.mois],
|
||||
([id]) => {
|
||||
const demande = Number(id)
|
||||
if (Number.isFinite(demande) && demande !== lotChoisi.value) lotChoisi.value = demande
|
||||
moisChoisis.value = moisDeLUrl()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
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)
|
||||
lotChoisi.value = lots.value.some((lot) => lot.id === demande)
|
||||
? demande
|
||||
|
||||
Reference in New Issue
Block a user