From 8dfb4af98ad0769968df0406300811d74259e00d Mon Sep 17 00:00:00 2001 From: bach Date: Tue, 8 Sep 2026 15:00:22 +0200 Subject: [PATCH] Nouvelle page /dashboard/repartition, renomme "Dashboard" en "SAS" dans le menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouvelle page "Répartition/Soldes" entre "SAS" (ex-"Dashboard") et "Par compte" dans le menu : reprend "Solde par compte" et "Évolution du solde par compte", retirés du dashboard général pour le recentrer sur l'activité/CA/type/client. Mêmes données déjà exposées par /dashboard/api/stats (solde_par_compte, solde_par_compte_par_annee), aucun changement backend nécessaire pour cette page. js/dashboard-repartition.js reprend le HBarChart/MiniTrend de dashboard.js -- dupliqués plutôt que partagés, même convention que dashboard-compte.js. Racine Vue volontairement le même id #figli-dashboard-app que les deux autres pages dashboard (pas un id dédié) : dashboard.css scope ses variables CSS (thème clair/sombre) sur ce sélecteur, réutiliser le même id est comment les trois pages héritent du même thème sans feuille de style séparée -- vérifié en dark mode. Le lien de menu "Dashboard" devient "SAS" partout (les 3 templates Twig + le nav en render array de HistoryController, qui n'a pas de template Twig propre). dashboard.js : MiniTrend/soldeParCompteItems/comptesOrdonnes/ trendValues supprimés (code mort après le déplacement, plus rien ne les utilise sur le dashboard général). --- .../figli_compta_ledger.libraries.yml | 10 ++ .../figli_compta_ledger.module | 5 + .../figli_compta_ledger.routing.yml | 8 + .../js/dashboard-repartition.js | 163 ++++++++++++++++++ .../figli_compta_ledger/js/dashboard.js | 67 +------ .../src/Controller/DashboardController.php | 18 +- .../src/Controller/HistoryController.php | 11 +- .../figli-compta-dashboard-compte.html.twig | 3 +- ...gli-compta-dashboard-repartition.html.twig | 39 +++++ .../figli-compta-dashboard.html.twig | 25 +-- .../templates/figli-compta-home.html.twig | 3 +- 11 files changed, 267 insertions(+), 85 deletions(-) create mode 100644 web/modules/custom/figli_compta_ledger/js/dashboard-repartition.js create mode 100644 web/modules/custom/figli_compta_ledger/templates/figli-compta-dashboard-repartition.html.twig diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.libraries.yml b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.libraries.yml index e26db2f..56d5ee1 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.libraries.yml +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.libraries.yml @@ -25,6 +25,16 @@ dashboard: - core/drupal - figli_compta_ledger/vue +dashboard_repartition: + js: + js/dashboard-repartition.js: {} + css: + theme: + css/dashboard.css: {} + dependencies: + - core/drupal + - figli_compta_ledger/vue + dashboard_compte: js: js/dashboard-compte.js: {} diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module index ce1f434..a5f65d2 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module @@ -481,6 +481,10 @@ function figli_compta_ledger_theme($existing, $type, $theme, $path) { 'variables' => ['current_route' => NULL], 'template' => 'figli-compta-dashboard', ], + 'figli_compta_dashboard_repartition' => [ + 'variables' => ['current_route' => NULL], + 'template' => 'figli-compta-dashboard-repartition', + ], 'figli_compta_dashboard_compte' => [ 'variables' => ['current_route' => NULL], 'template' => 'figli-compta-dashboard-compte', @@ -503,6 +507,7 @@ function figli_compta_ledger_page_attachments(array &$attachments) { $front_end_routes = [ 'figli_compta_ledger.home', 'figli_compta_ledger.dashboard', + 'figli_compta_ledger.dashboard_repartition', 'figli_compta_ledger.dashboard_compte', 'figli_compta_ledger.history', 'figli_compta_ledger.link_entree', diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml index 3ebcca5..451b8f4 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml @@ -14,6 +14,14 @@ figli_compta_ledger.dashboard: requirements: _permission: 'access content' +figli_compta_ledger.dashboard_repartition: + path: '/dashboard/repartition' + defaults: + _controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::repartitionView' + _title: 'Répartition / Soldes - SAS Figures Libres' + requirements: + _permission: 'access content' + figli_compta_ledger.dashboard_compte: path: '/dashboard/compte' defaults: diff --git a/web/modules/custom/figli_compta_ledger/js/dashboard-repartition.js b/web/modules/custom/figli_compta_ledger/js/dashboard-repartition.js new file mode 100644 index 0000000..ec4219b --- /dev/null +++ b/web/modules/custom/figli_compta_ledger/js/dashboard-repartition.js @@ -0,0 +1,163 @@ +/** + * @file + * "Répartition / Soldes": solde par compte (all-time bar chart + one + * year-by-year trend per compte), split out of the general /dashboard so + * that page stays focused on activity/CA/type/client breakdowns. Same + * /dashboard/api/stats endpoint as dashboard.js/dashboard-compte.js + * (DashboardStatsController -- plain SQL GROUP BY, not Entity API), no + * new backend needed -- solde_par_compte/solde_par_compte_par_annee were + * already in that response, just unused on this page until now. + * + * HBarChart/MiniTrend are duplicated from dashboard.js rather than + * shared, same established convention as dashboard-compte.js's own + * copies (see dashboard.css's comment on the Signalement filter rules). + * Root element id is deliberately the same #figli-dashboard-app as the + * other two dashboard pages (not a unique id) -- dashboard.css scopes + * its CSS custom properties (--figli-bg, dark-mode overrides, etc.) to + * that selector, and reusing it is how all three dashboard pages pick + * those up without a separate stylesheet. + */ +(function (Drupal, Vue) { + 'use strict'; + + const EUR_ROUND = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }); + + 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 -- same shape as dashboard.js's own copy, + // including the zero-centered "diverging" layout for negative values + // (a compte's solde can be a débit). + 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) }}
' + + '
' + + '
', + }; + + // Small multiples: one compact zero-centered bar-per-year trend per + // compte, instead of a single 8-series line chart -- same reasoning + // and same markup as dashboard.js's own copy. + 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, MiniTrend }, + data() { + return { loading: true, error: null, stats: null }; + }, + computed: { + 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); + }, + // 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); + }, + }, + methods: { + 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; + }); + }, + 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.figliComptaDashboardRepartition = { + 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); diff --git a/web/modules/custom/figli_compta_ledger/js/dashboard.js b/web/modules/custom/figli_compta_ledger/js/dashboard.js index bcba57e..9a6b04a 100644 --- a/web/modules/custom/figli_compta_ledger/js/dashboard.js +++ b/web/modules/custom/figli_compta_ledger/js/dashboard.js @@ -1,11 +1,12 @@ /** * @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. + * 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'; @@ -125,41 +126,8 @@ '', }; - // 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 }, + components: { HBarChart, ColumnChart: VBarChart }, data() { return { loading: true, error: null, stats: null }; }, @@ -168,12 +136,6 @@ 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) @@ -214,13 +176,6 @@ return { annee, items }; }).filter((y) => y.items.length); }, - // 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); @@ -242,12 +197,6 @@ 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'; }, diff --git a/web/modules/custom/figli_compta_ledger/src/Controller/DashboardController.php b/web/modules/custom/figli_compta_ledger/src/Controller/DashboardController.php index 4e86fb4..5bc0f05 100644 --- a/web/modules/custom/figli_compta_ledger/src/Controller/DashboardController.php +++ b/web/modules/custom/figli_compta_ledger/src/Controller/DashboardController.php @@ -40,7 +40,23 @@ class DashboardController extends ControllerBase { } /** - * Third page: one compte associé (freelance) at a time -- entrées client + * Third page: solde par compte, all-time and year by year -- split out + * of the general /dashboard so that page stays focused on activité/CA/ + * type/client rather than per-compte balances. Same + * /dashboard/api/stats data as /dashboard, just a different slice of it. + */ + public function repartitionView() { + return [ + '#theme' => 'figli_compta_dashboard_repartition', + '#current_route' => 'figli_compta_ledger.dashboard_repartition', + '#attached' => [ + 'library' => ['figli_compta_ledger/dashboard_repartition'], + ], + ]; + } + + /** + * Fourth page: one compte associé (freelance) at a time -- entrées client * vs versements freelance, and above all which entrées haven't been * (fully) paid out yet. Complements the aggregate /dashboard above, * which mixes every compte and every type together. diff --git a/web/modules/custom/figli_compta_ledger/src/Controller/HistoryController.php b/web/modules/custom/figli_compta_ledger/src/Controller/HistoryController.php index 611474e..31e2b1f 100644 --- a/web/modules/custom/figli_compta_ledger/src/Controller/HistoryController.php +++ b/web/modules/custom/figli_compta_ledger/src/Controller/HistoryController.php @@ -62,10 +62,10 @@ class HistoryController extends ControllerBase { // pinned next to the page title) -- this controller has no twig // template of its own to put a real