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).
164 lines
6.0 KiB
JavaScript
164 lines
6.0 KiB
JavaScript
/**
|
|
* @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:
|
|
'<div class="figli-hbar-chart">' +
|
|
'<div class="figli-hbar-row" v-for="item in items" :key="item.label">' +
|
|
'<div class="figli-hbar-label" :title="item.label">{{ item.label }}</div>' +
|
|
'<div class="figli-hbar-track" :class="{\'is-diverging\': hasNegative}">' +
|
|
'<div class="figli-hbar-zero" v-if="hasNegative"></div>' +
|
|
'<div class="figli-hbar-fill" :style="[fillStyle(item), {background: fillColor(item)}]"></div>' +
|
|
'</div>' +
|
|
'<div class="figli-hbar-value">{{ formatValue(item.value) }}</div>' +
|
|
'</div>' +
|
|
'</div>',
|
|
};
|
|
|
|
// 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:
|
|
'<div class="figli-mini-trend">' +
|
|
'<div class="figli-mini-bar-col" v-for="(v, i) in values" :key="annees[i]" :title="annees[i] + \' : \' + (v === null ? \'—\' : formatValue(v))">' +
|
|
'<div class="figli-mini-bar-track">' +
|
|
'<div class="figli-mini-bar-fill" :class="v !== null && v < 0 ? \'is-negative\' : \'is-positive\'" :style="{height: barHeight(v)}"></div>' +
|
|
'</div>' +
|
|
'<div class="figli-mini-bar-label">{{ annees[i].slice(2) }}</div>' +
|
|
'</div>' +
|
|
'</div>',
|
|
};
|
|
|
|
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);
|