gigafibre-fsm/apps/ops/src/pages/ClientsPage.vue
louispaulb 3d52ae32c4 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>
2026-07-04 19:13:37 -04:00

146 lines
6.1 KiB
Vue

<template>
<q-page padding>
<!-- 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) -->
<DataTable
:rows="clients" :columns="columns" row-key="name"
class="ops-table"
:loading="loading"
:pagination="pagination"
@request="onRequest"
@row-click="(_, row) => $router.push('/clients/' + row.name)"
style="cursor:pointer"
>
<template #body-cell-status="props">
<q-td :props="props">
<span class="ops-badge" :class="props.row.disabled ? 'inactive' : 'active'">
{{ props.row.disabled ? 'Inactif' : 'Actif' }}
</span>
</q-td>
</template>
<template #body-cell-customer_name="props">
<q-td :props="props">
<div class="text-weight-medium" @dblclick.stop>
<InlineField :value="props.row.customer_name" field="customer_name" doctype="Customer" :docname="props.row.name"
placeholder="Nom" @saved="v => props.row.customer_name = v.value" />
</div>
<div class="text-caption text-grey-6">
{{ props.row.name }}
<template v-if="props.row.legacy_customer_id"> &middot; {{ props.row.legacy_customer_id }}</template>
</div>
</q-td>
</template>
<template #body-cell-customer_type="props">
<q-td :props="props" @dblclick.stop>
<InlineField :value="props.row.customer_type" field="customer_type" doctype="Customer" :docname="props.row.name"
type="select" :options="['Individual', 'Company']"
@saved="v => props.row.customer_type = v.value" />
</q-td>
</template>
<template #body-cell-customer_group="props">
<q-td :props="props" @dblclick.stop>
<InlineField :value="props.row.customer_group" field="customer_group" doctype="Customer" :docname="props.row.name"
type="select" :options="customerGroups"
@saved="v => props.row.customer_group = v.value" />
</q-td>
</template>
</DataTable>
</q-page>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { Notify } from 'quasar'
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 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)
const pagination = ref({ page: 1, rowsPerPage: 25, rowsNumber: 0 })
let searchTimer = null
const columns = [
{ name: 'customer_name', label: 'Client', field: 'customer_name', align: 'left', sortable: true },
{ name: 'legacy_customer_id', label: 'ID Legacy', field: 'legacy_customer_id', align: 'left' },
{ name: 'customer_type', label: 'Type', field: 'customer_type', align: 'left' },
{ name: 'customer_group', label: 'Groupe', field: 'customer_group', align: 'left' },
{ name: 'territory', label: 'Territoire', field: 'territory', align: 'left' },
{ name: 'status', label: 'Statut', align: 'center', field: row => (row.disabled ? 'Inactif' : 'Actif') },
]
// 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)
}
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
const q = (filterState.value.search || '').trim()
if (q) {
const like = '%' + q + '%'
or_filters = [
['customer_name', 'like', like],
['name', 'like', like],
['legacy_customer_id', 'like', like],
]
}
try {
const [data, count] = await Promise.all([
listDocs('Customer', {
filters,
or_filters,
fields: ['name', 'customer_name', 'customer_type', 'customer_group', 'territory', 'disabled', 'legacy_customer_id'],
limit: pagination.value.rowsPerPage,
offset: (pagination.value.page - 1) * pagination.value.rowsPerPage,
orderBy: 'customer_name asc',
}),
countDocs('Customer', filters, or_filters),
])
clients.value = data
total.value = count
pagination.value.rowsNumber = count
} catch (e) {
Notify.create({ type: 'negative', message: 'Échec du chargement des clients — réessayez.' })
} finally {
loading.value = false
}
}
function onRequest (props) {
pagination.value.page = props.pagination.page
pagination.value.rowsPerPage = props.pagination.rowsPerPage
doSearch()
}
onMounted(() => doSearch())
watch(() => route.query.q, (q) => { if (q) { filterState.value = { ...filterState.value, search: q }; doSearch() } })
</script>