Nav: a small "Grand livre" / "Dashboard" switcher, top-right on both
pages, with the current page highlighted. Needed hook_theme() to
declare current_route as an accepted variable for both theme hooks --
same class of bug as the earlier can_view_history fix: an undeclared
variable passed via the render array is silently dropped rather than
reaching Twig.
Dashboard: replaced the old "solde par compte / par client" tables
(computed client-side from a full unwindowed JSON:API fetch of every
node -- the same performance problem the /lignes sliding window was
built to avoid, just not yet felt at 1500+ lines) with a single
aggregate endpoint (DashboardStatsController, plain SQL GROUP BY) and
five chart panels: CA par année, solde par compte (diverging,
red/green), solde par compte trend (small multiples per compte),
répartition par type, top clients par CA. No charting library --
small dependency-free div/CSS bar charts (HBarChart/ColumnChart/
MiniTrend components in dashboard.js), consistent with this project
vendoring its own JS.
Two data-correctness fixes along the way: (1) the historical stray
mistyped dates (0213-06-15, 2015-08-29 -- preserved as-is per this
module's policy) needed excluding from per-year buckets without
excluding their money from all-time totals, so the filtering happens
per-output-field in PHP rather than as a blanket SQL date range. (2)
PHP silently casts numeric-looking array keys ("2021") to actual
integers, so array_keys() on a year-keyed map produces a mix of ints
and strings -- json_encode emits the int ones as bare JSON numbers in
a list (unlike object keys, which JSON always stringifies), which
broke the frontend's annees[i].slice(2) trend-card labels. Fixed with
an explicit array_map('strval', ...).
Verified: bar widths/colors match the underlying data exactly (e.g.
EXT.'s red bar is proportionally sized against Maud's green one per
their actual solde ratio), all 8 trend cards render correct year
labels, and both nav links correctly highlight on their own page.
239 lines
8.5 KiB
JavaScript
239 lines
8.5 KiB
JavaScript
/**
|
|
* @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.
|
|
*/
|
|
(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',
|
|
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',
|
|
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 },
|
|
},
|
|
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>',
|
|
};
|
|
|
|
// 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:
|
|
'<div class="figli-vbar-chart">' +
|
|
'<div class="figli-vbar-col" v-for="item in items" :key="item.label">' +
|
|
'<div class="figli-vbar-value">{{ formatValue(item.value) }}</div>' +
|
|
'<div class="figli-vbar-track"><div class="figli-vbar-fill" :style="{height: barHeight(item)}"></div></div>' +
|
|
'<div class="figli-vbar-label">{{ item.label }}</div>' +
|
|
'</div>' +
|
|
'</div>',
|
|
};
|
|
|
|
// 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:
|
|
'<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, ColumnChart: VBarChart, MiniTrend },
|
|
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 }));
|
|
},
|
|
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)
|
|
.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 }));
|
|
},
|
|
// 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);
|
|
},
|
|
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);
|
|
},
|
|
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';
|
|
},
|
|
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);
|