From ec2af0ea7eb4c697817300aaf627bda212f0c889 Mon Sep 17 00:00:00 2001 From: bach Date: Sun, 6 Sep 2026 20:35:10 +0200 Subject: [PATCH] =?UTF-8?q?Add=20"R=C3=A9partition=20de=20l'activit=C3=A9?= =?UTF-8?q?=20par=20type"=20chart=20to=20/dashboard/compte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends DashboardStatsController::stats()'s répartition-level SQL query to also group by type (not just année/compte), producing a new total_par_type_par_compte breakdown alongside the existing global one. Unlike the reconciliation tables on this page (deliberately entrée/ versement only), this chart covers every type touching the selected compte's répartition, matching what the general /dashboard already shows for the whole ledger. Fixed a latent bug the query change would otherwise have introduced: solde_par_compte_par_annee[année][compte] used to be a 1:1 assignment because each (année, compte) pair was unique in the old query -- adding type to the GROUP BY means several rows can now share that same pair, so it has to accumulate instead of overwrite (verified the accumulated totals exactly match a query without the type split, so this preserves existing behavior for the fields already in use). HBarChart in dashboard-compte.js gained the same colorFor prop dashboard.js's version already has (existing Top clients usage keeps its default green, unaffected). Co-Authored-By: Claude Sonnet 5 --- .../js/dashboard-compte.js | 52 ++++++++++++++++++- .../Controller/DashboardStatsController.php | 34 ++++++++++-- .../figli-compta-dashboard-compte.html.twig | 6 +++ 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/web/modules/custom/figli_compta_ledger/js/dashboard-compte.js b/web/modules/custom/figli_compta_ledger/js/dashboard-compte.js index cb2506e..f8f6f34 100644 --- a/web/modules/custom/figli_compta_ledger/js/dashboard-compte.js +++ b/web/modules/custom/figli_compta_ledger/js/dashboard-compte.js @@ -26,6 +26,30 @@ 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 MONTHS_SHORT = ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.']; + // Same labels/colors as dashboard.js's "Répartition de l'activité par + // type" -- duplicated rather than shared, see this file's docblock. + 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', + }; + 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', + }; function resolve(includedMap, ref) { if (!ref) return null; @@ -181,22 +205,31 @@ '', }; + // Same shape as dashboard.js's HBarChart, colorFor included (used by + // the type-breakdown chart; Top clients below just omits it and gets + // the plain positive-green default). const HBarChart = { props: { items: { type: Array, required: true }, formatValue: { type: Function, required: true }, + colorFor: { type: Function, default: null }, }, computed: { maxAbs() { return Math.max(1, ...this.items.map((i) => Math.abs(i.value))); }, }, + methods: { + fillColor(item) { + return this.colorFor ? this.colorFor(item) : 'var(--figli-positive)'; + }, + }, template: '
' + '
' + '
{{ item.label }}
' + '
' + - '
' + + '
' + '
' + '
{{ formatValue(item.value) }}
' + '
' + @@ -378,6 +411,20 @@ .sort((a, b) => b.value - a.value) .slice(0, 10); }, + // Same chart as /dashboard's "Répartition de l'activité par type", + // filtered to this compte -- unlike the reconciliation tables above + // (deliberately entrée/versement only, see this file's docblock), + // this comes straight from /dashboard/api/stats's per-compte + // breakdown, so it covers every type touching this compte's + // répartition (charge, achat, hébergement...), matching what the + // general dashboard shows for the whole ledger. + typeItems() { + if (!this.stats || !this.selectedCompte) return []; + const parType = this.stats.total_par_type_par_compte[this.selectedCompte] || {}; + return Object.entries(parType) + .map(([type, value]) => ({ label: TYPE_LABELS[type] || type, value, type })) + .sort((a, b) => b.value - a.value); + }, }, methods: { formatEur(v) { @@ -386,6 +433,9 @@ formatEurRound(v) { return EUR_ROUND.format(v); }, + typeColor(item) { + return TYPE_COLORS[item.type] || '#6b7280'; + }, formatDate(d) { if (!d) return ''; const parts = d.split('-'); diff --git a/web/modules/custom/figli_compta_ledger/src/Controller/DashboardStatsController.php b/web/modules/custom/figli_compta_ledger/src/Controller/DashboardStatsController.php index e834192..4974864 100644 --- a/web/modules/custom/figli_compta_ledger/src/Controller/DashboardStatsController.php +++ b/web/modules/custom/figli_compta_ledger/src/Controller/DashboardStatsController.php @@ -57,11 +57,14 @@ class DashboardStatsController extends ControllerBase { $nodeRows = $nodeQuery->execute()->fetchAll(); // Répartition-level aggregate: one row per (node, compte) répartition - // share, pre-summed per (annee, compte) in SQL -- backs solde par - // compte, both all-time and per-year (each year's own total already - // includes that year's ouverture line, so it *is* that year's closing - // balance -- same logic as LedgerStatsController::totauxAnnee()). + // share, pre-summed per (annee, compte, type) in SQL -- backs solde + // par compte, both all-time and per-year (each year's own total + // already includes that year's ouverture line, so it *is* that + // year's closing balance -- same logic as + // LedgerStatsController::totauxAnnee()), and the per-compte type + // breakdown used by /dashboard/compte. $compteQuery = $connection->select('node__field_date_ligne', 'd'); + $compteQuery->innerJoin('node__field_type_ligne', 't2', 't2.entity_id = d.entity_id'); $compteQuery->innerJoin('node__field_repartition', 'r', 'r.entity_id = d.entity_id'); $compteQuery->innerJoin('paragraph__field_montant', 'm', 'm.entity_id = r.field_repartition_target_id'); $compteQuery->innerJoin('paragraph__field_compte', 'c', 'c.entity_id = r.field_repartition_target_id'); @@ -69,9 +72,11 @@ class DashboardStatsController extends ControllerBase { $compteQuery->condition('d.bundle', 'ligne_comptable'); $compteQuery->addExpression('SUBSTRING(d.field_date_ligne_value, 1, 4)', 'annee'); $compteQuery->addField('tc', 'name', 'compte'); + $compteQuery->addField('t2', 'field_type_ligne_value', 'type'); $compteQuery->addExpression('SUM(m.field_montant_value)', 'total'); $compteQuery->groupBy('annee'); $compteQuery->groupBy('compte'); + $compteQuery->groupBy('type'); $compteRows = $compteQuery->execute()->fetchAll(); // --- Aggregate the node-level rows in PHP. --- @@ -114,16 +119,31 @@ class DashboardStatsController extends ControllerBase { // --- Aggregate the répartition-level rows in PHP. --- $soldeParCompte = []; $soldeParCompteParAnnee = []; + $totalParTypeParCompte = []; foreach ($compteRows as $row) { $total = (float) $row->total; // Same reasoning: the all-time balance includes every line; the // per-year trend only makes sense for a real year. $soldeParCompte[$row->compte] = ($soldeParCompte[$row->compte] ?? 0) + $total; + + // Per-compte équivalent of $totalParType above -- every type + // counts here (not just entree/versement), same "hors ouverture, + // valeur absolue" convention. + if ($row->type !== 'ouverture') { + $totalParTypeParCompte[$row->compte][$row->type] = + ($totalParTypeParCompte[$row->compte][$row->type] ?? 0) + abs($total); + } + if (!$this->isAnneeValide($row->annee)) { continue; } $annees[$row->annee] = TRUE; - $soldeParCompteParAnnee[$row->annee][$row->compte] = round($total, 2); + // compteRows now has one row per (année, compte, type) -- several + // types can share the same (année, compte), so this has to + // accumulate, not overwrite, or only the last type processed for + // that year+compte would survive. + $soldeParCompteParAnnee[$row->annee][$row->compte] = + round(($soldeParCompteParAnnee[$row->annee][$row->compte] ?? 0) + $total, 2); } // array_keys() alone would leak PHP's array-key int-casting here: a @@ -148,6 +168,10 @@ class DashboardStatsController extends ControllerBase { 'top_clients' => $topClients, 'solde_par_compte' => array_map(fn ($v) => round($v, 2), $soldeParCompte), 'solde_par_compte_par_annee' => $soldeParCompteParAnnee, + 'total_par_type_par_compte' => array_map( + fn ($parType) => array_map(fn ($v) => round($v, 2), $parType), + $totalParTypeParCompte + ), ]); } diff --git a/web/modules/custom/figli_compta_ledger/templates/figli-compta-dashboard-compte.html.twig b/web/modules/custom/figli_compta_ledger/templates/figli-compta-dashboard-compte.html.twig index a964041..278e66d 100644 --- a/web/modules/custom/figli_compta_ledger/templates/figli-compta-dashboard-compte.html.twig +++ b/web/modules/custom/figli_compta_ledger/templates/figli-compta-dashboard-compte.html.twig @@ -144,6 +144,12 @@ +
+

Répartition de l'activité par type

+

Montant total (HT, valeur absolue) par type de ligne pour {{ selectedCompte }}, hors ouvertures -- contrairement aux tableaux ci-dessus, tous les types comptent ici (pas seulement entrée/versement).

+ +
+

Top clients

Clients ayant généré le plus d'entrées attribuées à {{ selectedCompte }}, toutes années confondues.