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