/** * @file * Dashboard: charts and aggregate totals (solde par compte, chiffre * d'affaires par année, répartition par type, top clients), computed * server-side (DashboardStatsController -- plain SQL GROUP BY, not Entity * API) and rendered here as small dependency-free div/CSS bar charts. No * charting library: this project vendors its own JS (see js/vendor/), and * a handful of bar/line charts don't warrant pulling one in. */ (function (Drupal, Vue) { 'use strict'; const EUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }); const EUR_ROUND = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }); const TYPE_LABELS = { entree: 'Entrée client', charge: 'Charge structurelle', versement: 'Versement freelance', achat: 'Achat client', hebergement: 'Hébergement', sous_traitant: 'Sous-traitant', salaire_stage: 'Salaire / stage', charges_local_pro: 'Charges local pro', autre: 'Autre', }; // Stable colour per type, independent of sort order -- a viewer // comparing this chart across page loads shouldn't see "achat" change // colour just because its rank shifted. const TYPE_COLORS = { entree: '#1a7f37', charge: '#6b7280', versement: '#d97a0a', achat: '#3b6fe0', hebergement: '#0e9182', sous_traitant: '#c9312b', salaire_stage: '#0891b2', charges_local_pro: '#65a30d', autre: '#9061f0', }; async function fetchStats() { const res = await fetch('/dashboard/api/stats', { headers: { Accept: 'application/json' } }); if (!res.ok) throw new Error('/dashboard/api/stats a répondu ' + res.status); return res.json(); } // Horizontal bar chart -- one row per item, label left, proportional // bar, value right. Switches to a zero-centered "diverging" layout // automatically when values can be negative (solde par compte), so a // debit and a credit of the same magnitude read as mirror images // instead of one dwarfing the other from a shared zero baseline. const HBarChart = { props: { items: { type: Array, required: true }, formatValue: { type: Function, required: true }, colorFor: { type: Function, default: null }, }, computed: { hasNegative() { return this.items.some((i) => i.value < 0); }, maxAbs() { return Math.max(1, ...this.items.map((i) => Math.abs(i.value))); }, }, methods: { fillStyle(item) { const pct = (Math.abs(item.value) / this.maxAbs) * 100; if (this.hasNegative) { return item.value >= 0 ? { left: '50%', width: pct / 2 + '%' } : { right: '50%', width: pct / 2 + '%' }; } return { left: 0, width: pct + '%' }; }, fillColor(item) { if (this.colorFor) return this.colorFor(item); return item.value < 0 ? 'var(--figli-error)' : 'var(--figli-positive)'; }, }, template: '
' + '
' + '
{{ item.label }}
' + '
' + '
' + '
' + '
' + '
{{ formatValue(item.value) }}
' + '
' + '
', }; // Vertical bar chart -- for a short time series (CA par année): a // handful of columns read left-to-right as a trend more naturally than // horizontal bars would. const VBarChart = { props: { items: { type: Array, required: true }, formatValue: { type: Function, required: true }, }, computed: { max() { return Math.max(1, ...this.items.map((i) => i.value)); }, }, methods: { barHeight(item) { return Math.max(2, (item.value / this.max) * 100) + '%'; }, }, template: '
' + '
' + '
{{ formatValue(item.value) }}
' + '
' + '
{{ item.label }}
' + '
' + '
', }; // Small multiples: one compact zero-centered bar-per-year trend per // compte, instead of a single 8-series line chart -- eight overlapping // lines sharing one small area is hard to read; eight small independent // trends, each answering "is this person's balance growing or // shrinking", is not. const MiniTrend = { props: { annees: { type: Array, required: true }, values: { type: Array, required: true }, formatValue: { type: Function, required: true }, }, computed: { maxAbs() { return Math.max(1, ...this.values.filter((v) => v !== null).map((v) => Math.abs(v))); }, }, methods: { barHeight(v) { if (v === null) return '0%'; return Math.max(3, (Math.abs(v) / this.maxAbs) * 100) + '%'; }, }, template: '
' + '
' + '
' + '
' + '
' + '
{{ annees[i].slice(2) }}
' + '
' + '
', }; const App = { components: { HBarChart, ColumnChart: VBarChart, MiniTrend }, data() { return { loading: true, error: null, stats: null }; }, computed: { caParAnneeItems() { if (!this.stats) return []; return this.stats.annees.map((y) => ({ label: y, value: this.stats.ca_par_annee[y] || 0 })); }, soldeParCompteItems() { if (!this.stats) return []; return Object.entries(this.stats.solde_par_compte) .map(([label, value]) => ({ label, value })) .sort((a, b) => b.value - a.value); }, typeItems() { if (!this.stats) return []; return Object.entries(this.stats.total_par_type) .map(([type, value]) => ({ label: TYPE_LABELS[type] || type, value, type })) .sort((a, b) => b.value - a.value); }, topClientsItems() { if (!this.stats) return []; return this.stats.top_clients.map((c) => ({ label: c.client, value: c.ca })); }, // Comptes ordered by all-time solde (richest first) -- same order // as soldeParCompteItems, so the trend grid below reads as a // continuation of the bar chart above it rather than an unrelated // shuffle. comptesOrdonnes() { return this.soldeParCompteItems.map((i) => i.label); }, totalCA() { if (!this.stats) return 0; return Object.values(this.stats.ca_par_annee).reduce((a, b) => a + b, 0); }, caAnneeEnCours() { if (!this.stats || !this.stats.annees.length) return null; const derniere = this.stats.annees[this.stats.annees.length - 1]; return { annee: derniere, value: this.stats.ca_par_annee[derniere] || 0 }; }, totalActivite() { if (!this.stats) return 0; return Object.values(this.stats.total_par_type).reduce((a, b) => a + b, 0); }, }, methods: { formatEur(v) { return EUR.format(v); }, formatEurRound(v) { return EUR_ROUND.format(v); }, trendValues(compte) { return this.stats.annees.map((y) => { const parAnnee = this.stats.solde_par_compte_par_annee[y]; return parAnnee && parAnnee[compte] !== undefined ? parAnnee[compte] : null; }); }, typeColor(item) { return TYPE_COLORS[item.type] || '#6b7280'; }, async load() { this.loading = true; this.error = null; try { this.stats = await fetchStats(); } catch (err) { this.error = err.message; } finally { this.loading = false; } }, }, mounted() { this.load(); }, }; Drupal.behaviors.figliComptaDashboard = { attach(context) { const root = context.querySelector ? context.querySelector('#figli-dashboard-app') : null; if (root && !root.dataset.figliInitialized) { root.dataset.figliInitialized = '1'; Vue.createApp(App).mount(root); } }, }; })(Drupal, Vue);