Rebuild /dashboard with charts, add Grand livre/Dashboard nav
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.
This commit is contained in:
@@ -1,107 +1,219 @@
|
||||
/**
|
||||
* @file
|
||||
* Aggregate dashboard: solde par compte / solde par client, computed
|
||||
* client-side from JSON:API. The line-by-line spreadsheet view is the
|
||||
* site's home page (home.js), not this one.
|
||||
* 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 API_BASE = '/jsonapi/node/ligne_comptable';
|
||||
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 fetchAllLignes() {
|
||||
// sort by nid: without an explicit, unique sort key, offset pagination
|
||||
// can silently duplicate or skip rows across pages.
|
||||
let url = API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client&page[limit]=50&sort=drupal_internal__nid';
|
||||
const allData = [];
|
||||
const includedMap = new Map();
|
||||
while (url) {
|
||||
const res = await fetch(url, { headers: { Accept: 'application/vnd.api+json' } });
|
||||
if (!res.ok) throw new Error('JSON:API a répondu ' + res.status);
|
||||
const json = await res.json();
|
||||
allData.push(...(json.data || []));
|
||||
(json.included || []).forEach((item) => includedMap.set(item.type + ':' + item.id, item));
|
||||
url = json.links && json.links.next ? json.links.next.href : null;
|
||||
}
|
||||
// Defensive de-dup by node id, in case pagination ever repeats a row.
|
||||
const seen = new Set();
|
||||
const dedup = allData.filter((n) => (seen.has(n.id) ? false : (seen.add(n.id), true)));
|
||||
return { data: dedup, includedMap };
|
||||
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();
|
||||
}
|
||||
|
||||
function resolve(includedMap, ref) {
|
||||
if (!ref) return null;
|
||||
return includedMap.get(ref.type + ':' + ref.id) || null;
|
||||
}
|
||||
// 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>',
|
||||
};
|
||||
|
||||
function addTo(map, key, montant) {
|
||||
if (!map.has(key)) map.set(key, { entrees: 0, sorties: 0 });
|
||||
const row = map.get(key);
|
||||
if (montant >= 0) row.entrees += montant;
|
||||
else row.sorties += montant;
|
||||
}
|
||||
// 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>',
|
||||
};
|
||||
|
||||
function computeAggregations(data, includedMap) {
|
||||
const parComptes = new Map();
|
||||
const parClients = new Map();
|
||||
for (const node of data) {
|
||||
const rels = node.relationships || {};
|
||||
const clientTerm = resolve(includedMap, rels.field_client && rels.field_client.data);
|
||||
const clientName = clientTerm ? clientTerm.attributes.name : '(sans client)';
|
||||
const repartitionRefs = (rels.field_repartition && rels.field_repartition.data) || [];
|
||||
for (const ref of repartitionRefs) {
|
||||
const paragraph = resolve(includedMap, ref);
|
||||
if (!paragraph) continue;
|
||||
const montant = parseFloat(paragraph.attributes.field_montant || 0);
|
||||
const compteTerm = resolve(includedMap, paragraph.relationships && paragraph.relationships.field_compte && paragraph.relationships.field_compte.data);
|
||||
const compteName = compteTerm ? compteTerm.attributes.name : '(compte inconnu)';
|
||||
addTo(parComptes, compteName, montant);
|
||||
addTo(parClients, clientName, montant);
|
||||
}
|
||||
}
|
||||
return { parComptes, parClients };
|
||||
}
|
||||
|
||||
function mapToRows(map) {
|
||||
return Array.from(map.entries())
|
||||
.map(([name, v]) => ({ name, entrees: v.entrees, sorties: v.sorties, solde: v.entrees + v.sorties }))
|
||||
.sort((a, b) => a.solde - b.solde);
|
||||
}
|
||||
|
||||
function totalsOf(rows) {
|
||||
return rows.reduce(
|
||||
(acc, r) => ({ entrees: acc.entrees + r.entrees, sorties: acc.sorties + r.sorties, solde: acc.solde + r.solde }),
|
||||
{ entrees: 0, sorties: 0, solde: 0 }
|
||||
);
|
||||
}
|
||||
// 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, tables: [], lineCount: 0 };
|
||||
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);
|
||||
},
|
||||
rowClass(solde) {
|
||||
if (solde > 0.5) return 'positive';
|
||||
if (solde < -0.5) return 'negative';
|
||||
return '';
|
||||
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 {
|
||||
const { data, includedMap } = await fetchAllLignes();
|
||||
this.lineCount = data.length;
|
||||
const { parComptes, parClients } = computeAggregations(data, includedMap);
|
||||
const comptesRows = mapToRows(parComptes);
|
||||
const clientsRows = mapToRows(parClients);
|
||||
this.tables = [
|
||||
{ title: 'Solde par compte', note: this.lineCount + ' lignes comptables chargées.', rows: comptesRows, totals: totalsOf(comptesRows) },
|
||||
{ title: 'Solde par client', note: 'Entrées créditées par client vs. montants sortis (versements, achats, charges) sur les lignes rattachées à ce client.', rows: clientsRows, totals: totalsOf(clientsRows) },
|
||||
];
|
||||
this.stats = await fetchStats();
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user