/** * @file * Dashboard: charts and aggregate totals (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. Solde par * compte lives on its own page (js/dashboard-repartition.js). */ (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 }, // Narrower label/value columns, smaller text -- for the per-année // small-multiples grids, where a full-width chart wouldn't fit in // a grid card. Same prop as dashboard-compte.js's own HBarChart // copy (this project duplicates the component rather than sharing // it between dashboard.js/dashboard-compte.js, see dashboard.css). compact: { type: Boolean, default: false }, }, 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: '
', }; // 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: '', }; const App = { components: { HBarChart, ColumnChart: VBarChart }, 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 })); }, 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 })); }, // Small multiples, one per year -- same source as typeItems() above // (total_par_type_par_annee is the same node-level SQL query, just // also grouped by année, no extra request). Mirrors // dashboard-compte.js's typeItemsParAnnee, but that one filters a // full row list client-side (it has one, scoped to a single // compte); this page never fetches full rows (see this file's // docblock), so the per-année breakdown has to already be // pre-aggregated server-side. typeItemsParAnnee() { if (!this.stats) return []; return this.stats.annees.map((annee) => { const parType = this.stats.total_par_type_par_annee[annee] || {}; const items = Object.entries(parType) .map(([type, value]) => ({ label: TYPE_LABELS[type] || type, value, type })) .sort((a, b) => b.value - a.value); return { annee, items }; }).filter((y) => y.items.length); }, // Same idea for topClientsItems() -- top_clients_par_annee is // already capped to 8 per year server-side (see // DashboardStatsController::stats()), same reasoning as // dashboard-compte.js capping its own per-année version to 5. topClientsParAnnee() { if (!this.stats) return []; return this.stats.annees.map((annee) => { const items = (this.stats.top_clients_par_annee[annee] || []) .map((c) => ({ label: c.client, value: c.ca })); return { annee, items }; }).filter((y) => y.items.length); }, // Charges structurelles by client (i.e. by vendor: loyer, // assurance, hébergement, URSSAF...) -- sorted by value descending // server-side already (no alphabetical option asked for here, // unlike the versements-par-compte chart), no top-N cap needed // (see DashboardStatsController::stats()'s own comment -- only a // handful of distinct vendors ever show up on a "charge" line). // `type: 'charge'` on every item is just so colorFor="typeColor" // (same method the type-breakdown chart uses) paints every bar the // same "charge" grey -- every row here is that type by definition. chargeParClientItems() { if (!this.stats) return []; return this.stats.charge_par_client.map((c) => ({ label: c.client, value: c.total, type: 'charge' })); }, chargeParClientParAnnee() { if (!this.stats) return []; return this.stats.annees.map((annee) => { const items = (this.stats.charge_par_client_par_annee[annee] || []) .map((c) => ({ label: c.client, value: c.total, type: 'charge' })); return { annee, items }; }).filter((y) => y.items.length); }, // total_par_type_par_compte is already in the stats response -- // it's the same répartition-level query /dashboard/compte uses for // its own (single-compte) type breakdown, just never sliced down // to one type across every compte before now. No new backend // field needed, just pull out the "versement" entry per compte. // Salaire/stage and sous-traitant are the two other ways money // leaves the collective toward a person/entity outside the 6 // comptes associés -- added as two extra rows (not split per // compte, there's only one meaningful total each) so the chart // reads as "who/what actually got paid", not just the 6 associés. // Sourced from total_par_type (all-time)/total_par_type_par_annee, // same fields typeItems()/typeItemsParAnnee() above already use. // `type` on every item is what colorFor="typeColor" keys off of // (same typeColor() method typeItems() already feeds) -- the 6 // compte rows are coloured as "versement" (they're that same // money, just broken down by recipient instead of summed), and // the two extra rows keep their own type colour. versementsParCompteItems() { if (!this.stats) return []; // Alphabetical among the 6 comptes, but Salaire/stage and // Sous-traitant always pushed on afterward -- they're not // comptes, so they stay out of that ordering and just close out // the list, in that fixed order. const items = Object.entries(this.stats.total_par_type_par_compte) .filter(([, parType]) => parType.versement !== undefined) .map(([compte, parType]) => ({ label: compte, value: parType.versement, type: 'versement' })) .sort((a, b) => a.label.localeCompare(b.label, 'fr')); const parType = this.stats.total_par_type || {}; if (parType.salaire_stage) items.push({ label: TYPE_LABELS.salaire_stage, value: parType.salaire_stage, type: 'salaire_stage' }); if (parType.sous_traitant) items.push({ label: TYPE_LABELS.sous_traitant, value: parType.sous_traitant, type: 'sous_traitant' }); return items; }, // Small multiples, one per year -- same source as typeItemsParAnnee // above (total_par_type_par_compte_par_annee, already there for // /dashboard/compte's own per-année breakdown). versementsParCompteParAnnee() { if (!this.stats) return []; return this.stats.annees.map((annee) => { const parCompte = this.stats.total_par_type_par_compte_par_annee[annee] || {}; const items = Object.entries(parCompte) .filter(([, parType]) => parType.versement !== undefined) .map(([compte, parType]) => ({ label: compte, value: parType.versement, type: 'versement' })) .sort((a, b) => a.label.localeCompare(b.label, 'fr')); const parType = this.stats.total_par_type_par_annee[annee] || {}; if (parType.salaire_stage) items.push({ label: TYPE_LABELS.salaire_stage, value: parType.salaire_stage, type: 'salaire_stage' }); if (parType.sous_traitant) items.push({ label: TYPE_LABELS.sous_traitant, value: parType.sous_traitant, type: 'sous_traitant' }); return { annee, items }; }).filter((y) => y.items.length); }, 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); }, typeColor(item) { return TYPE_COLORS[item.type] || '#6b7280'; }, // Sum of an hbar chart's own items -- for the "Total" line under // Charges structurelles par client (main chart and each per-année // card): those items are already whatever's actually plotted, so // summing them directly (rather than a separate stats field) keeps // the total consistent with the bars above it by construction. sumItems(items) { return items.reduce((sum, i) => sum + i.value, 0); }, 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);