Files
bachir af36591d70 Ajoute le total en bas de chaque graph Charges structurelles par client
Ligne "Total" sous le graph principal et sous chacune des cartes par
année -- somme calculée à partir des items déjà tracés (sumItems()), pas
d'un champ stats séparé, pour rester cohérente par construction avec les
barres affichées au-dessus.
2026-09-08 15:38:17 +02:00

308 lines
14 KiB
JavaScript

/**
* @file
* 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';
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',
sous_traitant: 'Sous-traitant',
salaire_stage: 'Salaire / stage',
charges_local_pro: 'Charges local pro',
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',
sous_traitant: '#c9312b',
salaire_stage: '#0891b2',
charges_local_pro: '#65a30d',
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 },
// Narrower label/value columns, smaller text -- for the per-année
// small-multiples grids, where a full-width chart wouldn't fit in
// a grid card. Same prop as dashboard-compte.js's own HBarChart
// copy (this project duplicates the component rather than sharing
// it between dashboard.js/dashboard-compte.js, see dashboard.css).
compact: { type: Boolean, default: false },
},
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" :class="{\'is-compact\': compact}">' +
'<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>',
};
const App = {
components: { HBarChart, ColumnChart: VBarChart },
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 }));
},
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 }));
},
// Small multiples, one per year -- same source as typeItems() above
// (total_par_type_par_annee is the same node-level SQL query, just
// also grouped by année, no extra request). Mirrors
// dashboard-compte.js's typeItemsParAnnee, but that one filters a
// full row list client-side (it has one, scoped to a single
// compte); this page never fetches full rows (see this file's
// docblock), so the per-année breakdown has to already be
// pre-aggregated server-side.
typeItemsParAnnee() {
if (!this.stats) return [];
return this.stats.annees.map((annee) => {
const parType = this.stats.total_par_type_par_annee[annee] || {};
const items = Object.entries(parType)
.map(([type, value]) => ({ label: TYPE_LABELS[type] || type, value, type }))
.sort((a, b) => b.value - a.value);
return { annee, items };
}).filter((y) => y.items.length);
},
// Same idea for topClientsItems() -- top_clients_par_annee is
// already capped to 8 per year server-side (see
// DashboardStatsController::stats()), same reasoning as
// dashboard-compte.js capping its own per-année version to 5.
topClientsParAnnee() {
if (!this.stats) return [];
return this.stats.annees.map((annee) => {
const items = (this.stats.top_clients_par_annee[annee] || [])
.map((c) => ({ label: c.client, value: c.ca }));
return { annee, items };
}).filter((y) => y.items.length);
},
// Charges structurelles by client (i.e. by vendor: loyer,
// assurance, hébergement, URSSAF...) -- sorted by value descending
// server-side already (no alphabetical option asked for here,
// unlike the versements-par-compte chart), no top-N cap needed
// (see DashboardStatsController::stats()'s own comment -- only a
// handful of distinct vendors ever show up on a "charge" line).
// `type: 'charge'` on every item is just so colorFor="typeColor"
// (same method the type-breakdown chart uses) paints every bar the
// same "charge" grey -- every row here is that type by definition.
chargeParClientItems() {
if (!this.stats) return [];
return this.stats.charge_par_client.map((c) => ({ label: c.client, value: c.total, type: 'charge' }));
},
chargeParClientParAnnee() {
if (!this.stats) return [];
return this.stats.annees.map((annee) => {
const items = (this.stats.charge_par_client_par_annee[annee] || [])
.map((c) => ({ label: c.client, value: c.total, type: 'charge' }));
return { annee, items };
}).filter((y) => y.items.length);
},
// total_par_type_par_compte is already in the stats response --
// it's the same répartition-level query /dashboard/compte uses for
// its own (single-compte) type breakdown, just never sliced down
// to one type across every compte before now. No new backend
// field needed, just pull out the "versement" entry per compte.
// Salaire/stage and sous-traitant are the two other ways money
// leaves the collective toward a person/entity outside the 6
// comptes associés -- added as two extra rows (not split per
// compte, there's only one meaningful total each) so the chart
// reads as "who/what actually got paid", not just the 6 associés.
// Sourced from total_par_type (all-time)/total_par_type_par_annee,
// same fields typeItems()/typeItemsParAnnee() above already use.
// `type` on every item is what colorFor="typeColor" keys off of
// (same typeColor() method typeItems() already feeds) -- the 6
// compte rows are coloured as "versement" (they're that same
// money, just broken down by recipient instead of summed), and
// the two extra rows keep their own type colour.
versementsParCompteItems() {
if (!this.stats) return [];
// Alphabetical among the 6 comptes, but Salaire/stage and
// Sous-traitant always pushed on afterward -- they're not
// comptes, so they stay out of that ordering and just close out
// the list, in that fixed order.
const items = Object.entries(this.stats.total_par_type_par_compte)
.filter(([, parType]) => parType.versement !== undefined)
.map(([compte, parType]) => ({ label: compte, value: parType.versement, type: 'versement' }))
.sort((a, b) => a.label.localeCompare(b.label, 'fr'));
const parType = this.stats.total_par_type || {};
if (parType.salaire_stage) items.push({ label: TYPE_LABELS.salaire_stage, value: parType.salaire_stage, type: 'salaire_stage' });
if (parType.sous_traitant) items.push({ label: TYPE_LABELS.sous_traitant, value: parType.sous_traitant, type: 'sous_traitant' });
return items;
},
// Small multiples, one per year -- same source as typeItemsParAnnee
// above (total_par_type_par_compte_par_annee, already there for
// /dashboard/compte's own per-année breakdown).
versementsParCompteParAnnee() {
if (!this.stats) return [];
return this.stats.annees.map((annee) => {
const parCompte = this.stats.total_par_type_par_compte_par_annee[annee] || {};
const items = Object.entries(parCompte)
.filter(([, parType]) => parType.versement !== undefined)
.map(([compte, parType]) => ({ label: compte, value: parType.versement, type: 'versement' }))
.sort((a, b) => a.label.localeCompare(b.label, 'fr'));
const parType = this.stats.total_par_type_par_annee[annee] || {};
if (parType.salaire_stage) items.push({ label: TYPE_LABELS.salaire_stage, value: parType.salaire_stage, type: 'salaire_stage' });
if (parType.sous_traitant) items.push({ label: TYPE_LABELS.sous_traitant, value: parType.sous_traitant, type: 'sous_traitant' });
return { annee, items };
}).filter((y) => y.items.length);
},
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);
},
typeColor(item) {
return TYPE_COLORS[item.type] || '#6b7280';
},
// Sum of an hbar chart's own items -- for the "Total" line under
// Charges structurelles par client (main chart and each per-année
// card): those items are already whatever's actually plotted, so
// summing them directly (rather than a separate stats field) keeps
// the total consistent with the bars above it by construction.
sumItems(items) {
return items.reduce((sum, i) => sum + i.value, 0);
},
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);