feat: add analytics

This commit is contained in:
2026-01-19 21:40:10 +01:00
parent 0fd6bdaeb3
commit aac4d194e3
19 changed files with 1940 additions and 347 deletions

View File

@@ -0,0 +1,92 @@
<template>
<div class="bg-gray-900 rounded-lg p-4">
<h3 class="text-sm font-medium text-gray-300 mb-4">Repartition par categorie</h3>
<div class="h-64">
<Doughnut
v-if="chartData.labels.length > 0"
:data="chartData"
:options="chartOptions"
/>
<div v-else class="h-full flex items-center justify-center text-gray-500 text-sm">
Aucune donnee
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Doughnut } from 'vue-chartjs'
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'
ChartJS.register(ArcElement, Tooltip, Legend)
const props = defineProps({
data: {
type: Array,
default: () => []
}
})
const colors = [
'#3B82F6', // blue
'#EF4444', // red
'#10B981', // green
'#F59E0B', // amber
'#8B5CF6', // purple
'#EC4899', // pink
'#06B6D4', // cyan
'#84CC16', // lime
'#F97316', // orange
'#6366F1', // indigo
]
const chartData = computed(() => {
const items = props.data.slice(0, 10) // Top 10 categories
return {
labels: items.map(d => formatCategorie(d.categorie)),
datasets: [{
data: items.map(d => d.total_debit),
backgroundColor: colors,
borderColor: '#1F2937',
borderWidth: 2
}]
}
})
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
color: '#9CA3AF',
font: { size: 11 },
boxWidth: 12,
padding: 8
}
},
tooltip: {
callbacks: {
label: (ctx) => {
const value = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(ctx.raw)
return ` ${value}`
}
}
}
}
}
function formatCategorie(cat) {
if (!cat) return 'Non categorise'
return cat
.replace(/_/g, ' ')
.toLowerCase()
.replace(/^\w/, c => c.toUpperCase())
.substring(0, 25)
}
</script>

View File

@@ -0,0 +1,217 @@
<template>
<div class="bg-gray-900 rounded-lg overflow-hidden">
<div class="px-4 py-3 border-b border-gray-700 flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-300">
Detail des depenses
<span class="text-gray-500 font-normal ml-2">({{ depenses.length }} resultats)</span>
</h3>
<button
v-if="depenses.length > 0"
@click="exportCsv"
class="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Exporter CSV
</button>
</div>
<div v-if="isLoading" class="p-8 text-center text-gray-500">
Chargement...
</div>
<div v-else-if="depenses.length === 0" class="p-8 text-center text-gray-500">
Aucune depense trouvee avec les filtres actuels.
</div>
<div v-else class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-800/50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-400 uppercase">Date</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-400 uppercase">Immeuble</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-400 uppercase">Lot</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-400 uppercase">Categorie</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-400 uppercase">Fournisseur</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-400 uppercase">Description</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-400 uppercase">Tag</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-400 uppercase">Debit</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-400 uppercase">Credit</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800">
<tr
v-for="dep in paginatedDepenses"
:key="dep.id"
class="hover:bg-gray-800/50"
>
<td class="px-3 py-2 text-gray-300 whitespace-nowrap">
{{ formatDate(dep.document_date) }}
</td>
<td class="px-3 py-2 text-gray-400 whitespace-nowrap">
{{ dep.immeuble_code }}
</td>
<td class="px-3 py-2 text-gray-400">
{{ dep.lot_numero || '-' }}
</td>
<td class="px-3 py-2 text-gray-300">
<div class="text-xs text-gray-500">{{ formatCategorie(dep.categorie) }}</div>
<div v-if="dep.sous_categorie" class="text-xs text-gray-400 truncate max-w-[150px]">
{{ dep.sous_categorie }}
</div>
</td>
<td class="px-3 py-2 text-white truncate max-w-[150px]">
{{ dep.fournisseur || '-' }}
</td>
<td class="px-3 py-2 text-gray-400 truncate max-w-[200px]">
{{ dep.description || '-' }}
</td>
<td class="px-3 py-2">
<span
v-if="dep.tag_nom"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-500/20 text-blue-400"
>
{{ dep.tag_nom }}
</span>
<span v-else class="text-gray-600">-</span>
</td>
<td class="px-3 py-2 text-right text-red-400 font-mono whitespace-nowrap">
{{ dep.debit > 0 ? formatCurrency(dep.debit) : '-' }}
</td>
<td class="px-3 py-2 text-right text-green-400 font-mono whitespace-nowrap">
{{ dep.credit > 0 ? formatCurrency(dep.credit) : '-' }}
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" class="px-4 py-3 border-t border-gray-700 flex items-center justify-between">
<div class="text-xs text-gray-500">
Affichage {{ startIndex + 1 }}-{{ Math.min(endIndex, depenses.length) }} sur {{ depenses.length }}
</div>
<div class="flex items-center gap-1">
<button
@click="currentPage = 1"
:disabled="currentPage === 1"
class="px-2 py-1 text-xs rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
&lt;&lt;
</button>
<button
@click="currentPage--"
:disabled="currentPage === 1"
class="px-2 py-1 text-xs rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
&lt;
</button>
<span class="px-3 text-xs text-gray-400">
Page {{ currentPage }} / {{ totalPages }}
</span>
<button
@click="currentPage++"
:disabled="currentPage === totalPages"
class="px-2 py-1 text-xs rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
&gt;
</button>
<button
@click="currentPage = totalPages"
:disabled="currentPage === totalPages"
class="px-2 py-1 text-xs rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
&gt;&gt;
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
depenses: {
type: Array,
default: () => []
},
isLoading: {
type: Boolean,
default: false
}
})
const pageSize = 25
const currentPage = ref(1)
// Reset page when depenses change
watch(() => props.depenses, () => {
currentPage.value = 1
})
const totalPages = computed(() => Math.ceil(props.depenses.length / pageSize))
const startIndex = computed(() => (currentPage.value - 1) * pageSize)
const endIndex = computed(() => startIndex.value + pageSize)
const paginatedDepenses = computed(() =>
props.depenses.slice(startIndex.value, endIndex.value)
)
function formatDate(dateStr) {
if (!dateStr) return '-'
const d = new Date(dateStr)
return d.toLocaleDateString('fr-FR')
}
function formatCategorie(cat) {
if (!cat) return '-'
return cat
.replace(/_/g, ' ')
.toLowerCase()
.replace(/^\w/, c => c.toUpperCase())
}
function formatCurrency(value) {
if (value == null) return '-'
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(value)
}
function exportCsv() {
const headers = [
'Date', 'Reference', 'Immeuble', 'Lot', 'Categorie', 'Sous-categorie',
'Fournisseur', 'Description', 'Tag', 'Debit', 'Credit', 'TVA', 'Locatif', 'Deductible'
]
const rows = props.depenses.map(d => [
d.document_date,
d.document_reference,
d.immeuble_code,
d.lot_numero || '',
d.categorie || '',
d.sous_categorie || '',
d.fournisseur || '',
d.description || '',
d.tag_nom || '',
d.debit,
d.credit,
d.tva,
d.locatif,
d.deductible
])
const csvContent = [
headers.join(';'),
...rows.map(r => r.map(v => `"${v}"`).join(';'))
].join('\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `depenses_${new Date().toISOString().split('T')[0]}.csv`
link.click()
}
</script>

View File

@@ -0,0 +1,208 @@
<template>
<div class="bg-gray-900 rounded-lg p-4 space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-300">Filtres</h3>
<button
v-if="hasActiveFilters"
@click="resetFilters"
class="text-xs text-blue-400 hover:text-blue-300"
>
Reinitialiser
</button>
</div>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<!-- Immeuble -->
<div>
<label class="block text-xs text-gray-400 mb-1">Immeuble</label>
<select
v-model="filters.immeuble_id"
@change="onImmeubleChange"
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
>
<option :value="null">Tous</option>
<option v-for="imm in immeubles" :key="imm.id" :value="imm.id">
{{ imm.code }} - {{ imm.adresse || 'N/A' }}
</option>
</select>
</div>
<!-- Lot -->
<div>
<label class="block text-xs text-gray-400 mb-1">Lot</label>
<select
v-model="filters.lot_id"
@change="emitFilters"
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
:disabled="!filters.immeuble_id"
>
<option :value="null">Tous</option>
<option v-for="lot in filteredLots" :key="lot.id" :value="lot.id">
{{ lot.numero }} {{ lot.type ? `(${lot.type})` : '' }}
</option>
</select>
</div>
<!-- Categorie -->
<div>
<label class="block text-xs text-gray-400 mb-1">Categorie</label>
<select
v-model="filters.categorie"
@change="emitFilters"
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
>
<option :value="null">Toutes</option>
<option v-for="cat in categories" :key="cat" :value="cat">
{{ formatCategorie(cat) }}
</option>
</select>
</div>
<!-- Tag -->
<div>
<label class="block text-xs text-gray-400 mb-1">Tag</label>
<select
v-model="filters.tag_id"
@change="emitFilters"
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
>
<option :value="null">Tous</option>
<option v-for="tag in tags" :key="tag.id" :value="tag.id">
{{ tag.nom }}
</option>
</select>
</div>
<!-- Date debut -->
<div>
<label class="block text-xs text-gray-400 mb-1">Date debut</label>
<input
type="date"
v-model="filters.date_debut"
@change="emitFilters"
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
/>
</div>
<!-- Date fin -->
<div>
<label class="block text-xs text-gray-400 mb-1">Date fin</label>
<input
type="date"
v-model="filters.date_fin"
@change="emitFilters"
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
/>
</div>
</div>
<!-- Fournisseur (recherche) -->
<div class="max-w-xs">
<label class="block text-xs text-gray-400 mb-1">Fournisseur</label>
<input
type="text"
v-model="filters.fournisseur"
@input="emitFilters"
placeholder="Rechercher..."
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
/>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
const emit = defineEmits(['filter-change'])
// Reference data
const immeubles = ref([])
const lots = ref([])
const categories = ref([])
const tags = ref([])
// Local filters state
const filters = reactive({
immeuble_id: null,
lot_id: null,
categorie: null,
tag_id: null,
fournisseur: null,
date_debut: null,
date_fin: null
})
const filteredLots = computed(() => {
if (!filters.immeuble_id) return []
return lots.value.filter(l => l.immeuble_id === filters.immeuble_id)
})
const hasActiveFilters = computed(() => {
return filters.immeuble_id !== null ||
filters.lot_id !== null ||
filters.categorie !== null ||
filters.tag_id !== null ||
filters.date_debut !== null ||
filters.date_fin !== null ||
(filters.fournisseur && filters.fournisseur.length > 0)
})
function onImmeubleChange() {
// Reset lot when immeuble changes
filters.lot_id = null
emitFilters()
}
function emitFilters() {
console.log('Emitting filters:', { ...filters })
emit('filter-change', { ...filters })
}
function resetFilters() {
filters.immeuble_id = null
filters.lot_id = null
filters.categorie = null
filters.tag_id = null
filters.fournisseur = null
filters.date_debut = null
filters.date_fin = null
emitFilters()
}
function formatCategorie(cat) {
if (!cat) return '-'
return cat
.replace(/_/g, ' ')
.toLowerCase()
.replace(/^\w/, c => c.toUpperCase())
}
async function loadReferenceData() {
try {
const [immeublesRes, lotsRes, categoriesRes, tagsRes] = await Promise.all([
fetch('/api/immeubles'),
fetch('/api/lots'),
fetch('/api/analytics/categories'),
fetch('/api/tags')
])
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()
console.log('Reference data loaded:', {
immeubles: immeubles.value.length,
lots: lots.value.length,
categories: categories.value.length,
tags: tags.value.length
})
} catch (err) {
console.error('Failed to load reference data:', err)
}
}
onMounted(() => {
loadReferenceData()
})
</script>

View File

@@ -0,0 +1,49 @@
<template>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<div
v-for="kpi in kpis"
:key="kpi.key"
class="bg-gray-900 rounded-lg p-4"
>
<div class="text-xs text-gray-400 mb-1">{{ kpi.label }}</div>
<div class="text-xl font-bold" :class="kpi.colorClass || 'text-white'">
{{ formatValue(kpi.key, summary[kpi.key]) }}
</div>
<div v-if="kpi.subLabel" class="text-xs text-gray-500 mt-1">
{{ kpi.subLabel }}
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
summary: {
type: Object,
required: true
}
})
const kpis = [
{ key: 'total_count', label: 'Operations', colorClass: 'text-white' },
{ key: 'total_debit', label: 'Total Debit', colorClass: 'text-red-400' },
{ key: 'total_credit', label: 'Total Credit', colorClass: 'text-green-400' },
{ key: 'total_tva', label: 'Total TVA', colorClass: 'text-blue-400' },
{ key: 'total_locatif', label: 'Part Locative', colorClass: 'text-amber-400' },
{ key: 'total_deductible', label: 'Deductible', colorClass: 'text-purple-400' }
]
function formatValue(key, value) {
if (value === undefined || value === null) return '-'
if (key === 'total_count') {
return value.toLocaleString('fr-FR')
}
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0
}).format(value)
}
</script>

View File

@@ -0,0 +1,107 @@
<template>
<div class="bg-gray-900 rounded-lg p-4">
<h3 class="text-sm font-medium text-gray-300 mb-4">Evolution mensuelle</h3>
<div class="h-64">
<Bar
v-if="chartData.labels.length > 0"
:data="chartData"
:options="chartOptions"
/>
<div v-else class="h-full flex items-center justify-center text-gray-500 text-sm">
Aucune donnee
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend
} from 'chart.js'
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
const props = defineProps({
data: {
type: Array,
default: () => []
}
})
const monthNames = [
'Jan', 'Fev', 'Mar', 'Avr', 'Mai', 'Juin',
'Juil', 'Aout', 'Sep', 'Oct', 'Nov', 'Dec'
]
const chartData = computed(() => {
const items = props.data.slice(-24) // Last 24 months
return {
labels: items.map(d => `${monthNames[d.month - 1]} ${d.year}`),
datasets: [
{
label: 'Debit',
data: items.map(d => d.total_debit),
backgroundColor: '#EF4444',
borderRadius: 4
},
{
label: 'Credit',
data: items.map(d => d.total_credit),
backgroundColor: '#10B981',
borderRadius: 4
}
]
}
})
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
labels: {
color: '#9CA3AF',
font: { size: 11 },
boxWidth: 12,
padding: 15
}
},
tooltip: {
callbacks: {
label: (ctx) => {
const value = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(ctx.raw)
return ` ${ctx.dataset.label}: ${value}`
}
}
}
},
scales: {
x: {
ticks: { color: '#6B7280', font: { size: 10 } },
grid: { color: '#374151' }
},
y: {
ticks: {
color: '#6B7280',
callback: (value) => {
if (value >= 1000) return `${(value / 1000).toFixed(0)}k`
return value
}
},
grid: { color: '#374151' }
}
}
}
</script>

View File

@@ -0,0 +1,89 @@
<template>
<div class="bg-gray-900 rounded-lg p-4">
<h3 class="text-sm font-medium text-gray-300 mb-4">Top fournisseurs</h3>
<div class="h-64">
<Bar
v-if="chartData.labels.length > 0"
:data="chartData"
:options="chartOptions"
/>
<div v-else class="h-full flex items-center justify-center text-gray-500 text-sm">
Aucune donnee
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Tooltip
} from 'chart.js'
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip)
const props = defineProps({
data: {
type: Array,
default: () => []
}
})
const chartData = computed(() => {
const items = props.data.slice(0, 10) // Top 10
return {
labels: items.map(d => truncate(d.fournisseur || 'Non specifie', 20)),
datasets: [{
data: items.map(d => d.total_debit),
backgroundColor: '#3B82F6',
borderRadius: 4
}]
}
})
const chartOptions = {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => {
const value = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(ctx.raw)
return ` ${value}`
}
}
}
},
scales: {
x: {
ticks: {
color: '#6B7280',
callback: (value) => {
if (value >= 1000) return `${(value / 1000).toFixed(0)}k`
return value
}
},
grid: { color: '#374151' }
},
y: {
ticks: { color: '#9CA3AF', font: { size: 11 } },
grid: { display: false }
}
}
}
function truncate(str, len) {
if (!str) return ''
return str.length > len ? str.substring(0, len) + '...' : str
}
</script>