feat(ui): T5 audit — composant DynamicFilter réutilisable + intégré sur Clients

- DynamicFilter.vue : recherche + groupes de chips (tag/statut/…) + PRESETS sauvegardés par utilisateur
  (useUserPrefs, clé presetKey) ; contrôleur d'état ({search,chips}) émis → le parent applique (client OU serveur).
- ClientsPage : remplace search+toggle par <DynamicFilter> ; AJOUTE chips Statut/Groupe/Territoire (avant : statut seul) → pilotent la requête serveur ; presets 'filter.clients'.
- fix(erp): countDocs comptait FAUX avec or_filters (get_count ignore or_filters → 0 pendant une recherche) → compte via get_list quand or_filters présent. Vérifié : « bourdon » 0→33.

Vérifié préview : chips (Actifs 9008), recherche (bourdon 33 = lignes), menu presets. Build vert, leak 0. Réutilisable : Tickets/Network/Évaluations/Boîte ensuite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-04 19:13:37 -04:00
parent 8f11d41a42
commit 3d52ae32c4
3 changed files with 142 additions and 49 deletions

View File

@ -83,13 +83,24 @@ export async function deleteDoc (doctype, name) {
return true
}
// Count documents
// Count documents. ⚠️ frappe.client.get_count IGNORE or_filters (recherche OR) → il renverrait un compte
// faux (souvent 0) dès qu'on cherche. Quand or_filters est présent, on compte via get_list (fields=name,
// sans limite) — les jeux de recherche sont bornés par le terme tapé. Sinon (chips seuls) : get_count rapide.
export async function countDocs (doctype, filters = {}, or_filters) {
const params = new URLSearchParams({
doctype,
filters: JSON.stringify(filters),
})
if (or_filters) params.set('or_filters', JSON.stringify(or_filters))
if (or_filters && or_filters.length) {
const p = new URLSearchParams({
doctype,
filters: JSON.stringify(filters),
or_filters: JSON.stringify(or_filters),
fields: JSON.stringify(['name']),
limit_page_length: '0',
})
const r = await authFetch(BASE_URL + '/api/method/frappe.client.get_list?' + p)
if (!r.ok) return 0
const d = await r.json()
return ((d.message || d.data) || []).length
}
const params = new URLSearchParams({ doctype, filters: JSON.stringify(filters) })
const res = await authFetch(BASE_URL + '/api/method/frappe.client.get_count?' + params)
if (!res.ok) return 0
const data = await res.json()

View File

@ -0,0 +1,98 @@
<template>
<!-- Filtre dynamique RÉUTILISABLE : recherche + groupes de chips (tag/compétence/statut) + presets sauvegardés (par utilisateur).
Contrôleur d'ÉTAT : possède { search, chips } et émet ; le parent applique (filtre client OU requête serveur). -->
<div class="dynamic-filter" :class="{ dense }">
<div class="df-row">
<q-input dense outlined :model-value="state.search" clearable :placeholder="searchPlaceholder" class="df-search"
@update:model-value="v => setSearch(v || '')">
<template #prepend><q-icon name="search" size="18px" /></template>
</q-input>
<q-space />
<span v-if="filteredCount != null" class="df-count">{{ filteredCount }}<span v-if="totalCount != null" class="text-grey-5"> / {{ totalCount }}</span></span>
<q-btn v-if="hasActiveFilter" flat dense no-caps size="sm" icon="filter_alt_off" label="Effacer" color="grey-7" @click="clearAll" />
<q-btn v-if="enablePresets" dense outline no-caps size="sm" icon="bookmark" :label="activePreset || 'Presets'" color="primary">
<q-menu anchor="bottom right" self="top right">
<q-list dense style="min-width:230px">
<q-item-label header class="q-py-xs">Filtres enregistrés</q-item-label>
<q-item v-for="p in presets" :key="p.name" clickable @click="applyPreset(p)">
<q-item-section avatar><q-icon :name="p.name === activePreset ? 'bookmark' : 'bookmark_border'" size="18px" :color="p.name === activePreset ? 'primary' : 'grey-6'" /></q-item-section>
<q-item-section>{{ p.name }}</q-item-section>
<q-item-section side><q-btn flat dense round size="xs" icon="delete" color="grey-6" @click.stop="deletePreset(p)"><q-tooltip>Supprimer</q-tooltip></q-btn></q-item-section>
</q-item>
<q-item v-if="!presets.length"><q-item-section class="text-grey-6">Aucun filtre enregistré</q-item-section></q-item>
<q-separator />
<q-item clickable v-close-popup :disable="!hasActiveFilter" @click="saveCurrent">
<q-item-section avatar><q-icon name="bookmark_add" color="primary" /></q-item-section>
<q-item-section>Enregistrer le filtre actuel</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</div>
<div v-for="g in chipGroups" :key="g.key" class="df-chips">
<span v-if="g.label" class="df-chips-lbl"><q-icon v-if="g.icon" :name="g.icon" size="14px" :color="g.color || 'grey-6'" class="q-mr-xs" />{{ g.label }}</span>
<button v-for="o in g.options" :key="String(o.value)" type="button" class="df-chip" :class="{ on: isOn(g, o.value) }"
:style="isOn(g, o.value) && (o.color || g.color) ? { background: o.color || g.color, borderColor: o.color || g.color, color: '#fff' } : {}"
@click="toggleChip(g, o.value)">{{ o.label }}</button>
</div>
</div>
</template>
<script setup>
import { reactive, computed, watch, ref } from 'vue'
import { useQuasar } from 'quasar'
import { useUserPrefs } from 'src/composables/useUserPrefs'
const props = defineProps({
modelValue: { type: Object, default: () => ({ search: '', chips: {} }) },
searchPlaceholder: { type: String, default: 'Rechercher…' },
// [{ key, label, icon?, color?, options:[{value,label,color?}], multiple?:bool }]
chipGroups: { type: Array, default: () => [] },
presetKey: { type: String, default: '' }, // clé useUserPrefs (ex. 'filter.clients') ; requis pour les presets
enablePresets: { type: Boolean, default: true },
filteredCount: { type: Number, default: null },
totalCount: { type: Number, default: null },
dense: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue', 'change', 'clear'])
const $q = useQuasar()
const state = reactive({ search: props.modelValue.search || '', chips: { ...(props.modelValue.chips || {}) } })
watch(() => props.modelValue, (v) => { if (v) { state.search = v.search || ''; state.chips = { ...(v.chips || {}) } } })
function emitAll () { const snapshot = { search: state.search, chips: { ...state.chips } }; emit('update:modelValue', snapshot); emit('change', snapshot); activePreset.value = matchPresetName() }
function setSearch (v) { state.search = v; emitAll() }
function isOn (g, val) { const c = state.chips[g.key]; return g.multiple ? Array.isArray(c) && c.includes(val) : c === val }
function toggleChip (g, val) {
if (g.multiple) { const arr = Array.isArray(state.chips[g.key]) ? [...state.chips[g.key]] : []; const i = arr.indexOf(val); if (i >= 0) arr.splice(i, 1); else arr.push(val); if (arr.length) state.chips[g.key] = arr; else delete state.chips[g.key] } else { if (state.chips[g.key] === val) delete state.chips[g.key]; else state.chips[g.key] = val }
emitAll()
}
const hasActiveFilter = computed(() => !!state.search || Object.keys(state.chips).length > 0)
function clearAll () { state.search = ''; state.chips = {}; emitAll(); emit('clear') }
// Presets (par utilisateur, via useUserPrefs)
const { prefs, save } = useUserPrefs(props.presetKey || 'filter.default', { presets: [] })
const presets = computed(() => (prefs.value.presets || []))
const activePreset = ref('')
function serial (s) { return JSON.stringify({ search: s.search || '', chips: s.chips || {} }) }
function matchPresetName () { const cur = serial(state); const hit = presets.value.find(p => serial(p.state) === cur); return hit ? hit.name : '' }
function applyPreset (p) { state.search = p.state.search || ''; state.chips = { ...(p.state.chips || {}) }; emitAll() }
function deletePreset (p) { save({ presets: presets.value.filter(x => x.name !== p.name) }); if (activePreset.value === p.name) activePreset.value = '' }
function saveCurrent () {
$q.dialog({ title: 'Enregistrer le filtre', message: 'Nom du filtre :', prompt: { model: '', type: 'text' }, cancel: true, ok: { label: 'Enregistrer', noCaps: true } })
.onOk(name => { name = String(name || '').trim(); if (!name) return; const others = presets.value.filter(p => p.name !== name); save({ presets: [...others, { name, state: { search: state.search, chips: { ...state.chips } } }] }); activePreset.value = name })
}
</script>
<style scoped>
.dynamic-filter { display: flex; flex-direction: column; gap: 6px; }
.df-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.df-search { flex: 1 1 220px; min-width: 0; max-width: 340px; }
.df-count { font-size: 12px; color: var(--ops-text-secondary, #64748b); font-weight: 600; white-space: nowrap; }
.df-chips { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; }
.df-chips-lbl { display: inline-flex; align-items: center; font-size: 11.5px; font-weight: 600; color: var(--ops-text-secondary, #64748b); margin-right: 2px; }
.df-chip { display: inline-flex; align-items: center; padding: 3px 11px; border: 1px solid var(--ops-border, #cbd5e1); border-radius: 13px; background: #fff; font-size: 12px; font-weight: 600; color: #475569; cursor: pointer; user-select: none; transition: border-color .12s, background .12s; }
.df-chip:hover { border-color: #6366f1; }
.df-chip.on { background: #6366f1; border-color: #6366f1; color: #fff; }
.dense .df-chip { padding: 2px 9px; font-size: 11.5px; }
</style>

View File

@ -1,32 +1,9 @@
<template>
<q-page padding>
<!-- Search + filters -->
<div class="row items-center q-mb-md q-col-gutter-sm">
<div class="col-12 col-md-4">
<q-input
v-model="search" dense outlined placeholder="Nom, ID legacy, numéro de compte..."
class="ops-search" @update:model-value="onSearchInput" @keyup.enter="doSearch" autofocus
>
<template #prepend><q-icon name="search" /></template>
<template #append>
<q-spinner v-if="loading" size="16px" color="grey-5" />
<q-icon v-else-if="search" name="close" class="cursor-pointer" @click="search = ''; clients = []; total = 0" />
</template>
</q-input>
</div>
<div class="col-auto">
<q-btn-toggle v-model="statusFilter" no-caps dense unelevated
toggle-color="primary" color="grey-3" text-color="grey-8"
:options="[
{ label: 'Tous', value: 'all' },
{ label: 'Actifs', value: 'active' },
{ label: 'Inactifs', value: 'disabled' },
]"
@update:model-value="doSearch"
/>
</div>
<q-space />
<div class="text-caption text-grey-6">{{ total.toLocaleString() }} clients</div>
<!-- Recherche + filtres dynamiques réutilisables (recherche + chips statut/groupe/territoire + presets sauvegardés par utilisateur) -->
<div class="q-mb-md">
<DynamicFilter v-model="filterState" search-placeholder="Nom, ID legacy, numéro de compte"
:chip-groups="chipGroups" preset-key="filter.clients" :filtered-count="total" @change="onFilterChange" />
</div>
<!-- Client table DataTable : passe en cartes sous 1024px (sinon la table déborde sur mobile) -->
@ -83,11 +60,17 @@ import { useRoute } from 'vue-router'
import { listDocs, countDocs } from 'src/api/erp'
import InlineField from 'src/components/shared/InlineField.vue'
import DataTable from 'src/components/shared/DataTable.vue'
import DynamicFilter from 'src/components/shared/DynamicFilter.vue'
import { territoryOptions } from 'src/config/nav'
const route = useRoute()
const customerGroups = ['Commercial', 'Individual', 'Government', 'Non Profit']
const search = ref(route.query.q || '')
const statusFilter = ref('all') // défaut = Tous ; Actifs/Inactifs = filtres optionnels pour affiner
const filterState = ref({ search: route.query.q || '', chips: {} }) // recherche + chips (statut/groupe/territoire) pilote la requête serveur
const chipGroups = [
{ key: 'statut', label: 'Statut', icon: 'toggle_on', options: [{ value: 'active', label: 'Actifs' }, { value: 'disabled', label: 'Inactifs' }] },
{ key: 'group', label: 'Groupe', icon: 'category', options: customerGroups.map(g => ({ value: g, label: g })) },
{ key: 'territory', label: 'Territoire', icon: 'place', options: territoryOptions.map(t => ({ value: t.value, label: t.label })) },
]
const clients = ref([])
const loading = ref(false)
const total = ref(0)
@ -103,28 +86,29 @@ const columns = [
{ name: 'status', label: 'Statut', align: 'center', field: row => (row.disabled ? 'Inactif' : 'Actif') },
]
function onSearchInput () {
// DynamicFilter a émis un nouvel état recharge (débounce léger pour la frappe ; les chips passent aussi par ici).
function onFilterChange () {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
pagination.value.page = 1
doSearch()
}, 300)
searchTimer = setTimeout(() => { pagination.value.page = 1; doSearch() }, 300)
}
async function doSearch () {
loading.value = true
const filters = {}
let or_filters
const c = filterState.value.chips || {}
if (c.statut === 'active') filters.disabled = 0
else if (c.statut === 'disabled') filters.disabled = 1
if (c.group) filters.customer_group = c.group
if (c.territory) filters.territory = c.territory
if (statusFilter.value === 'active') filters.disabled = 0
else if (statusFilter.value === 'disabled') filters.disabled = 1
if (search.value.trim()) {
const q = '%' + search.value.trim() + '%'
const q = (filterState.value.search || '').trim()
if (q) {
const like = '%' + q + '%'
or_filters = [
['customer_name', 'like', q],
['name', 'like', q],
['legacy_customer_id', 'like', q],
['customer_name', 'like', like],
['name', 'like', like],
['legacy_customer_id', 'like', like],
]
}
@ -157,5 +141,5 @@ function onRequest (props) {
}
onMounted(() => doSearch())
watch(() => route.query.q, (q) => { if (q) { search.value = q; doSearch() } })
watch(() => route.query.q, (q) => { if (q) { filterState.value = { ...filterState.value, search: q }; doSearch() } })
</script>