feat: fait vivre les filtres d'analyse dans l'URL

Les filtres des pages Dépenses et Recettes n'existaient que dans l'état du
composant : recharger la page les perdait, et une vue filtrée ne pouvait ni
se partager, ni se mettre en signet, ni servir de destination à un lien.

Chaque page déclare desormais comment ses filtres se lisent et s'écrivent
dans la query (utils/filtresUrl.js). Ce qui vaut sa valeur par défaut n'est
pas écrit : l'URL ne porte que ce qui a été choisi, et reste lisible. Ce qui
en arrive est validé — un identifiant qui n'est pas un entier ou une date au
mauvais format est ignoré plutôt qu'affiché de travers.

Le changement de filtre remplace l'entrée d'historique au lieu d'en empiler
une : le bouton Retour du navigateur quitte la page, il ne défait pas les
filtres un par un.

Côté Dépenses, la requête API dérive de cette même écriture : les paramètres
sont les mêmes des deux côtés, la vue et la requête ne peuvent plus diverger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 18:27:01 +02:00
parent a5a1db9968
commit 78b9bbf129
6 changed files with 320 additions and 63 deletions

View File

@@ -0,0 +1,143 @@
// Les filtres d'une page d'analyse vivent dans son URL : une vue filtree se
// recharge, se partage et se remet en signet, et le retour depuis l'editeur de
// document n'a plus qu'une chaine a transporter.
//
// Chaque champ declare comment il se lit depuis la query et comment il s'y
// ecrit. Ce qui vaut sa valeur par defaut n'est pas ecrit : l'URL ne porte que
// ce qui a ete choisi, et reste lisible.
/** Un entier (identifiant). `tag_id=0` est une valeur, pas une absence. */
export function entier({ param } = {}) {
return {
param,
defaut: null,
lire(brut) {
const valeur = Number(premier(brut))
return Number.isInteger(valeur) ? valeur : null
},
ecrire(valeur) {
return valeur === null || valeur === undefined ? undefined : String(valeur)
},
}
}
/** Un texte libre choisi dans une liste fermee (categorie, type de ligne). */
export function texte({ param } = {}) {
return {
param,
defaut: null,
lire(brut) {
const valeur = premier(brut)
return valeur ? valeur : null
},
ecrire(valeur) {
return valeur ? valeur : undefined
},
}
}
/** Une date ISO. Un format inattendu est ignore plutot qu'affiche de travers. */
export function date({ param } = {}) {
return {
param,
defaut: null,
lire(brut) {
const valeur = premier(brut)
return valeur && /^\d{4}-\d{2}-\d{2}$/.test(valeur) ? valeur : null
},
ecrire(valeur) {
return valeur ? valeur : undefined
},
}
}
/** Un booleen a un seul sens : seul `true` s'ecrit, l'absence vaut faux. */
export function booleen({ param } = {}) {
return {
param,
defaut: false,
lire(brut) {
return premier(brut) === 'true'
},
ecrire(valeur) {
return valeur ? 'true' : undefined
},
}
}
/** Un entier borne avec une valeur par defaut (nombre de mois d'historique). */
export function entierBorne({ defaut, min, max, param } = {}) {
return {
param,
defaut,
lire(brut) {
const valeur = Number(premier(brut))
if (!Number.isInteger(valeur) || valeur < min || valeur > max) return defaut
return valeur
},
ecrire(valeur) {
return valeur === undefined || valeur === defaut ? undefined : String(valeur)
},
}
}
/** Une liste de textes, un parametre repete par valeur retenue. */
export function liste({ param } = {}) {
return {
param,
defaut: [],
lire(brut) {
if (brut === undefined || brut === null) return []
const valeurs = Array.isArray(brut) ? brut : [brut]
return valeurs.filter((valeur) => typeof valeur === 'string' && valeur !== '')
},
ecrire(valeur) {
return valeur && valeur.length ? [...valeur] : undefined
},
}
}
/** Les filtres d'une page, reconstruits depuis la query d'une route. */
export function lireFiltres(query, schema) {
const filtres = {}
for (const [nom, champ] of Object.entries(schema)) {
filtres[nom] = champ.lire((query ?? {})[champ.param ?? nom])
}
return filtres
}
/** La query correspondant a des filtres : seuls les choix explicites y figurent. */
export function ecrireFiltres(filtres, schema) {
const query = {}
for (const [nom, champ] of Object.entries(schema)) {
const ecrit = champ.ecrire(filtres[nom])
if (ecrit !== undefined) query[champ.param ?? nom] = ecrit
}
return query
}
/** vue-router rend une chaine ou un tableau selon le nombre d'occurrences. */
function premier(brut) {
return Array.isArray(brut) ? brut[0] : brut
}
/** Filtres de la page Depenses. `fournisseur` se repete, comme cote API. */
export const SCHEMA_DEPENSES = {
immeuble_id: entier(),
lot_id: entier(),
categorie: texte(),
tag_id: entier(),
fournisseurs: liste({ param: 'fournisseur' }),
date_debut: date(),
date_fin: date(),
}
/** Filtres de la page Recettes. */
export const SCHEMA_REVENUS = {
immeuble_id: entier(),
type_ligne: texte(),
date_debut: date(),
date_fin: date(),
impayes_only: booleen(),
months: entierBorne({ defaut: 12, min: 3, max: 24 }),
}