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:
197
frontend/src/components/SelectionMultiple.vue
Normal file
197
frontend/src/components/SelectionMultiple.vue
Normal 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) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
})[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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user