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>
152 lines
4.5 KiB
Vue
152 lines
4.5 KiB
Vue
<template>
|
|
<div class="page">
|
|
<div class="page-content">
|
|
|
|
<!-- Header -->
|
|
<div class="page-header">
|
|
<div>
|
|
<h1 class="page-title">Dépenses</h1>
|
|
<p class="page-subtitle">
|
|
Visualisez et filtrez les dépenses de vos immeubles
|
|
</p>
|
|
</div>
|
|
<div v-if="lastUpdate" class="text-xs text-gray-500">
|
|
Derniere mise a jour: {{ lastUpdate }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<FilterPanel :filtres-initiaux="currentFilters" @filter-change="onFiltersChange" />
|
|
|
|
<!-- KPI Cards -->
|
|
<KpiCards :summary="summary" />
|
|
|
|
<!-- Les deux repartitions cote a cote : meme total, deux decoupages. -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<CategoryChart :data="summary.by_category || []" />
|
|
<TagChart :data="summary.by_tag || []" />
|
|
</div>
|
|
|
|
<!-- L'evolution mensuelle prend toute la largeur : un axe de temps
|
|
serre est le premier a devenir illisible. -->
|
|
<MonthlyChart :data="summary.by_month || []" />
|
|
|
|
<!-- Top Fournisseurs -->
|
|
<TopFournisseursChart :data="summary.by_fournisseur || []" />
|
|
|
|
<!-- Depenses Table -->
|
|
<DepensesTable
|
|
:depenses="depenses"
|
|
:is-loading="isLoadingDepenses"
|
|
/>
|
|
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { SCHEMA_DEPENSES, ecrireFiltres, lireFiltres } from '../utils/filtresUrl.js'
|
|
import FilterPanel from '../components/analytics/FilterPanel.vue'
|
|
import KpiCards from '../components/analytics/KpiCards.vue'
|
|
import CategoryChart from '../components/analytics/CategoryChart.vue'
|
|
import TagChart from '../components/analytics/TagChart.vue'
|
|
import MonthlyChart from '../components/analytics/MonthlyChart.vue'
|
|
import TopFournisseursChart from '../components/analytics/TopFournisseursChart.vue'
|
|
import DepensesTable from '../components/analytics/DepensesTable.vue'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
// Les filtres viennent de l'URL : ouvrir un lien filtre, recharger la page ou
|
|
// revenir de l'editeur de document rend exactement la meme vue.
|
|
const currentFilters = ref(lireFiltres(route.query, SCHEMA_DEPENSES))
|
|
|
|
const summary = ref({
|
|
total_count: 0,
|
|
total_debit: 0,
|
|
total_credit: 0,
|
|
total_tva: 0,
|
|
total_locatif: 0,
|
|
total_deductible: 0,
|
|
by_category: [],
|
|
by_tag: [],
|
|
by_month: [],
|
|
by_fournisseur: []
|
|
})
|
|
|
|
const depenses = ref([])
|
|
const isLoadingDepenses = ref(false)
|
|
const lastUpdate = ref(null)
|
|
|
|
// Debounce timer
|
|
let debounceTimer = null
|
|
|
|
function onFiltersChange(newFilters) {
|
|
currentFilters.value = { ...newFilters }
|
|
|
|
// `replace` et non `push` : le bouton Retour du navigateur doit quitter la
|
|
// page, pas defaire les filtres un par un.
|
|
router.replace({ query: ecrireFiltres(currentFilters.value, SCHEMA_DEPENSES) })
|
|
|
|
// Debounce
|
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
debounceTimer = setTimeout(() => {
|
|
loadData()
|
|
}, 300)
|
|
}
|
|
|
|
/** L'API attend les memes parametres que l'URL de la page : une seule ecriture
|
|
des filtres, donc aucun moyen que la vue et la requete divergent. */
|
|
function buildQueryParams() {
|
|
const params = new URLSearchParams()
|
|
const query = ecrireFiltres(currentFilters.value, SCHEMA_DEPENSES)
|
|
|
|
for (const [cle, valeur] of Object.entries(query)) {
|
|
for (const unitaire of Array.isArray(valeur) ? valeur : [valeur]) {
|
|
params.append(cle, unitaire)
|
|
}
|
|
}
|
|
|
|
return params.toString()
|
|
}
|
|
|
|
async function loadData() {
|
|
const queryString = buildQueryParams()
|
|
const summaryUrl = queryString ? `/api/analytics/depenses/summary?${queryString}` : '/api/analytics/depenses/summary'
|
|
const depensesUrl = queryString ? `/api/analytics/depenses?${queryString}&limit=500` : '/api/analytics/depenses?limit=500'
|
|
|
|
console.log('Loading data with filters:', currentFilters.value)
|
|
console.log('Summary URL:', summaryUrl)
|
|
|
|
isLoadingDepenses.value = true
|
|
|
|
try {
|
|
const [summaryRes, depensesRes] = await Promise.all([
|
|
fetch(summaryUrl),
|
|
fetch(depensesUrl)
|
|
])
|
|
|
|
if (summaryRes.ok) {
|
|
summary.value = await summaryRes.json()
|
|
console.log('Summary loaded:', summary.value.total_count, 'depenses')
|
|
}
|
|
|
|
if (depensesRes.ok) {
|
|
depenses.value = await depensesRes.json()
|
|
}
|
|
|
|
lastUpdate.value = new Date().toLocaleTimeString('fr-FR')
|
|
} catch (err) {
|
|
console.error('Failed to load analytics data:', err)
|
|
} finally {
|
|
isLoadingDepenses.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadData()
|
|
})
|
|
</script>
|