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.
308 lines
14 KiB
JavaScript
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);
|