feat: filtre les dépenses sur plusieurs fournisseurs et sur l'absence de tag

Le filtre fournisseur était une recherche de sous-chaîne : impossible de
comparer deux fournisseurs, et « PPR » ramenait ses homonymes au passage.
Il devient une sélection multiple exacte, prise dans un menu déroulant
filtrable au clavier (SelectionMultiple, générique et réutilisable). La
liste vient de /api/fournisseurs, remise en ordre alphabétique — l'API la
trie par montant, ce qui se lit bien dans un classement mais rend
introuvable un fournisseur qu'on cherche à l'œil.

Côté API, `fournisseur` devient répétable et s'entend comme un OU exact.

Le filtre tag, lui, ne savait pas demander « ce qui n'est pas encore
tagué » — c'est pourtant la question qui amorce le travail de tagging.
`tag_id=0` le demande, et l'option « Sans tag » l'ouvre depuis la page.

Au passage, les deux endpoints dupliquaient leurs filtres : ils partagent
désormais _appliquer_filtres, pour que les totaux du résumé ne puissent
plus porter sur d'autres lignes que la table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 17:29:03 +02:00
parent 7c19676400
commit e81cd5ffa4
6 changed files with 500 additions and 55 deletions

View File

@@ -0,0 +1,197 @@
<template>
<div class="relative" ref="conteneur">
<!-- Le declencheur reprend l'allure d'un select : dans une grille de
filtres, un controle qui ne ressemble pas aux autres se cherche. -->
<button
type="button"
class="input flex items-center justify-between gap-2 text-left"
@click="basculerPanneau"
>
<span class="truncate" :class="selection.length ? 'text-white' : 'text-gray-500'">
{{ resume }}
</span>
<svg
class="w-4 h-4 shrink-0 text-gray-500 transition-transform"
:class="{ 'rotate-180': ouvert }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div
v-if="ouvert"
class="absolute z-50 w-full min-w-[16rem] mt-1 bg-gray-900 border border-gray-700 rounded-lg shadow-lg"
>
<div class="p-2 border-b border-gray-800">
<input
ref="champRecherche"
v-model="recherche"
type="text"
:placeholder="placeholderRecherche"
class="input"
@keydown.escape="fermerPanneau"
@keydown.enter.prevent="cocherLePremier"
/>
</div>
<div
v-if="selection.length"
class="px-3 py-1.5 flex items-center justify-between text-xs border-b border-gray-800"
>
<span class="text-gray-400">{{ selection.length }} {{ selection.length > 1 ? 'selectionnes' : 'selectionne' }}</span>
<button type="button" class="text-blue-400 hover:text-blue-300" @click="toutDecocher">
Tout decocher
</button>
</div>
<div class="max-h-60 overflow-y-auto custom-scrollbar">
<label
v-for="option in optionsFiltrees"
:key="option.valeur"
class="flex items-center gap-2 px-3 py-2 cursor-pointer text-sm hover:bg-gray-800"
>
<input
type="checkbox"
class="shrink-0 accent-blue-500"
:checked="estSelectionnee(option.valeur)"
@change="basculerOption(option.valeur)"
/>
<span class="flex-1 truncate text-gray-300" v-html="surligner(option.libelle)"></span>
<span v-if="option.complement" class="shrink-0 text-xs text-gray-500">
{{ option.complement }}
</span>
</label>
<div v-if="!optionsFiltrees.length" class="px-3 py-3 text-sm text-gray-500">
Aucun resultat pour « {{ recherche }} »
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = defineProps({
/** Valeurs cochees. Un tableau vide vaut « aucun filtre », pas « rien ». */
modelValue: { type: Array, default: () => [] },
/** [{ valeur, libelle, complement? }] */
options: { type: Array, default: () => [] },
libelleVide: { type: String, default: 'Tous' },
placeholderRecherche: { type: String, default: 'Rechercher...' },
/** Nom au pluriel employe dans le resume : « 3 fournisseurs ». */
nomPluriel: { type: String, default: 'elements' }
})
const emit = defineEmits(['update:modelValue'])
const conteneur = ref(null)
const champRecherche = ref(null)
const ouvert = ref(false)
const recherche = ref('')
const selection = computed(() => props.modelValue ?? [])
/** Une seule valeur cochee s'affiche en clair : le compte seul cacherait
laquelle, alors que c'est justement le cas ou on veut la lire. */
const resume = computed(() => {
if (!selection.value.length) return props.libelleVide
if (selection.value.length === 1) {
const option = props.options.find((o) => o.valeur === selection.value[0])
return option?.libelle ?? String(selection.value[0])
}
return `${selection.value.length} ${props.nomPluriel}`
})
const optionsFiltrees = computed(() => {
const requete = recherche.value.trim().toLowerCase()
if (!requete) return props.options
return props.options.filter((o) => o.libelle.toLowerCase().includes(requete))
})
function estSelectionnee(valeur) {
return selection.value.includes(valeur)
}
function basculerOption(valeur) {
const suivante = estSelectionnee(valeur)
? selection.value.filter((v) => v !== valeur)
: [...selection.value, valeur]
emit('update:modelValue', suivante)
}
/** Entree coche la seule option restante : filtrer puis valider est le geste
naturel quand on sait deja quel fournisseur on cherche. */
function cocherLePremier() {
if (optionsFiltrees.value.length) {
basculerOption(optionsFiltrees.value[0].valeur)
recherche.value = ''
}
}
function toutDecocher() {
emit('update:modelValue', [])
}
function basculerPanneau() {
ouvert.value ? fermerPanneau() : ouvrirPanneau()
}
function ouvrirPanneau() {
ouvert.value = true
recherche.value = ''
nextTick(() => champRecherche.value?.focus())
}
function fermerPanneau() {
ouvert.value = false
}
function surClicExterieur(evenement) {
if (ouvert.value && conteneur.value && !conteneur.value.contains(evenement.target)) {
fermerPanneau()
}
}
/** Les valeurs cochees qui ne figurent plus dans les options seraient
invisibles tout en filtrant : on les laisse tomber avec les options. */
watch(
() => props.options,
(options) => {
if (!options.length || !selection.value.length) return
const connues = new Set(options.map((o) => o.valeur))
const retenues = selection.value.filter((v) => connues.has(v))
if (retenues.length !== selection.value.length) {
emit('update:modelValue', retenues)
}
}
)
onMounted(() => document.addEventListener('mousedown', surClicExterieur))
onBeforeUnmount(() => document.removeEventListener('mousedown', surClicExterieur))
function echapper(texte) {
return texte.replace(/[&<>"']/g, (c) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[c])
}
function surligner(texte) {
const echappe = echapper(texte)
const requete = recherche.value.trim()
if (!requete) return echappe
const motif = echapper(requete).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return echappe.replace(
new RegExp(`(${motif})`, 'gi'),
'<mark class="bg-amber-500/30 text-amber-200 rounded px-0.5">$1</mark>'
)
}
</script>

View File

@@ -1,5 +1,7 @@
<template>
<div class="card card-body space-y-4">
<!-- overflow-visible : `.card` rogne son contenu, ce qui decapitait le menu
deroulant des fournisseurs. Rien d'autre ne deborde de cette carte. -->
<div class="card card-body space-y-4 overflow-visible">
<div class="flex items-center justify-between">
<h3 class="card-title">Filtres</h3>
<button
@@ -67,6 +69,9 @@
class="input"
>
<option :value="null">Tous</option>
<!-- « Sans tag » est un choix de filtre, pas l'absence de filtre :
c'est ainsi qu'on retrouve les depenses restant a tagger. -->
<option :value="SANS_TAG">Sans tag</option>
<option v-for="tag in tags" :key="tag.id" :value="tag.id">
{{ tag.nom }}
</option>
@@ -95,15 +100,16 @@
/>
</div>
<!-- Fournisseur (recherche) -->
<!-- Fournisseurs (selection multiple, filtrable au clavier) -->
<div>
<label class="block text-xs text-gray-400 mb-1">Fournisseur</label>
<input
type="text"
v-model="filters.fournisseur"
@input="emitFilters"
placeholder="Rechercher..."
class="input"
<label class="block text-xs text-gray-400 mb-1">Fournisseurs</label>
<SelectionMultiple
:model-value="filters.fournisseurs"
:options="optionsFournisseurs"
libelle-vide="Tous"
nom-pluriel="fournisseurs"
placeholder-recherche="Filtrer la liste..."
@update:model-value="onFournisseursChange"
/>
</div>
</div>
@@ -112,14 +118,19 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import SelectionMultiple from '../SelectionMultiple.vue'
const emit = defineEmits(['filter-change'])
/** Valeur de tag_id demandant les depenses sans tag (cf. SANS_TAG cote API). */
const SANS_TAG = 0
// Reference data
const immeubles = ref([])
const lots = ref([])
const categories = ref([])
const tags = ref([])
const fournisseurs = ref([])
// Local filters state
const filters = reactive({
@@ -127,11 +138,24 @@ const filters = reactive({
lot_id: null,
categorie: null,
tag_id: null,
fournisseur: null,
fournisseurs: [],
date_debut: null,
date_fin: null
})
/** Ordre alphabetique - l'API les trie par montant, ce qui se lit bien dans un
classement mais rend introuvable un fournisseur qu'on cherche a l'oeil. Le
nombre de depenses reste affiche a droite pour situer le poids. */
const optionsFournisseurs = computed(() =>
[...fournisseurs.value]
.sort((a, b) => a.nom.localeCompare(b.nom, 'fr'))
.map((f) => ({
valeur: f.nom,
libelle: f.nom,
complement: `${f.nb_depenses}`
}))
)
const filteredLots = computed(() => {
if (!filters.immeuble_id) return []
return lots.value.filter(l => l.immeuble_id === filters.immeuble_id)
@@ -144,7 +168,7 @@ const hasActiveFilters = computed(() => {
filters.tag_id !== null ||
filters.date_debut !== null ||
filters.date_fin !== null ||
(filters.fournisseur && filters.fournisseur.length > 0)
filters.fournisseurs.length > 0
})
function onImmeubleChange() {
@@ -153,6 +177,11 @@ function onImmeubleChange() {
emitFilters()
}
function onFournisseursChange(valeurs) {
filters.fournisseurs = valeurs
emitFilters()
}
function emitFilters() {
console.log('Emitting filters:', { ...filters })
emit('filter-change', { ...filters })
@@ -163,7 +192,7 @@ function resetFilters() {
filters.lot_id = null
filters.categorie = null
filters.tag_id = null
filters.fournisseur = null
filters.fournisseurs = []
filters.date_debut = null
filters.date_fin = null
emitFilters()
@@ -179,23 +208,26 @@ function formatCategorie(cat) {
async function loadReferenceData() {
try {
const [immeublesRes, lotsRes, categoriesRes, tagsRes] = await Promise.all([
const [immeublesRes, lotsRes, categoriesRes, tagsRes, fournisseursRes] = await Promise.all([
fetch('/api/immeubles'),
fetch('/api/lots'),
fetch('/api/analytics/categories'),
fetch('/api/tags')
fetch('/api/tags'),
fetch('/api/fournisseurs')
])
if (immeublesRes.ok) immeubles.value = await immeublesRes.json()
if (lotsRes.ok) lots.value = await lotsRes.json()
if (categoriesRes.ok) categories.value = await categoriesRes.json()
if (tagsRes.ok) tags.value = await tagsRes.json()
if (fournisseursRes.ok) fournisseurs.value = await fournisseursRes.json()
console.log('Reference data loaded:', {
immeubles: immeubles.value.length,
lots: lots.value.length,
categories: categories.value.length,
tags: tags.value.length
tags: tags.value.length,
fournisseurs: fournisseurs.value.length
})
} catch (err) {
console.error('Failed to load reference data:', err)

View File

@@ -55,7 +55,7 @@ let currentFilters = {
lot_id: null,
categorie: null,
tag_id: null,
fournisseur: null,
fournisseurs: [],
date_debut: null,
date_fin: null
}
@@ -97,7 +97,8 @@ function buildQueryParams() {
if (currentFilters.lot_id !== null) params.append('lot_id', currentFilters.lot_id)
if (currentFilters.categorie !== null) params.append('categorie', currentFilters.categorie)
if (currentFilters.tag_id !== null) params.append('tag_id', currentFilters.tag_id)
if (currentFilters.fournisseur) params.append('fournisseur', currentFilters.fournisseur)
// Un parametre `fournisseur` par fournisseur retenu : l'API en fait un OR.
for (const nom of currentFilters.fournisseurs || []) params.append('fournisseur', nom)
if (currentFilters.date_debut) params.append('date_debut', currentFilters.date_debut)
if (currentFilters.date_fin) params.append('date_fin', currentFilters.date_fin)

View File

@@ -0,0 +1,49 @@
// Le declencheur du menu multi-selection est ce qui reste visible une fois le
// menu referme : il doit dire l'etat du filtre sans qu'on ait a le rouvrir.
import { describe, expect, it } from 'vitest'
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
import SelectionMultiple from '../src/components/SelectionMultiple.vue'
const FOURNISSEURS = [
{ valeur: 'ACME', libelle: 'ACME', complement: '12' },
{ valeur: 'BOREAL', libelle: 'BOREAL', complement: '3' },
{ valeur: 'CERES', libelle: 'CERES', complement: '1' },
]
function rendu(proprietes = {}) {
return renderToString(
createSSRApp(SelectionMultiple, {
options: FOURNISSEURS,
libelleVide: 'Tous',
nomPluriel: 'fournisseurs',
...proprietes,
})
)
}
describe('SelectionMultiple', () => {
it('annonce l absence de filtre quand rien n est coche', async () => {
expect(await rendu({ modelValue: [] })).toContain('Tous')
})
it('nomme le fournisseur quand il n y en a qu un', async () => {
// Le compte seul (« 1 fournisseur ») cacherait lequel, alors que c'est
// justement le cas ou la reponse tient dans le bouton.
const html = await rendu({ modelValue: ['BOREAL'] })
expect(html).toContain('BOREAL')
expect(html).not.toContain('1 fournisseurs')
})
it('compte les fournisseurs des qu il y en a plusieurs', async () => {
expect(await rendu({ modelValue: ['ACME', 'CERES'] })).toContain('2 fournisseurs')
})
it('garde le menu ferme au premier rendu', async () => {
// Une liste de dizaines de fournisseurs deployee d'entree recouvrirait la
// page a chaque chargement.
expect(await rendu({ modelValue: [] })).not.toContain('Rechercher')
})
})