feat: add analytics
This commit is contained in:
92
frontend/src/components/analytics/CategoryChart.vue
Normal file
92
frontend/src/components/analytics/CategoryChart.vue
Normal 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>
|
||||
217
frontend/src/components/analytics/DepensesTable.vue
Normal file
217
frontend/src/components/analytics/DepensesTable.vue
Normal 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"
|
||||
>
|
||||
<<
|
||||
</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"
|
||||
>
|
||||
<
|
||||
</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"
|
||||
>
|
||||
>
|
||||
</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"
|
||||
>
|
||||
>>
|
||||
</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>
|
||||
208
frontend/src/components/analytics/FilterPanel.vue
Normal file
208
frontend/src/components/analytics/FilterPanel.vue
Normal 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>
|
||||
49
frontend/src/components/analytics/KpiCards.vue
Normal file
49
frontend/src/components/analytics/KpiCards.vue
Normal 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>
|
||||
107
frontend/src/components/analytics/MonthlyChart.vue
Normal file
107
frontend/src/components/analytics/MonthlyChart.vue
Normal 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>
|
||||
89
frontend/src/components/analytics/TopFournisseursChart.vue
Normal file
89
frontend/src/components/analytics/TopFournisseursChart.vue
Normal 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>
|
||||
Reference in New Issue
Block a user