Files
drupal-figli-compta/web/modules/custom/figli_compta_ledger/js/dashboard.js
T
bachir ef2d7b801d Add sous-traitant, salaire/stage, and charges local pro ligne types
Three new field_type_ligne values (config/sync +
figli_compta_ledger.install for fresh-install parity), wired through
every place that enumerates the type list: LedgerActionsController's
inline type-change endpoint, home.js's type dropdown/badge, dashboard.js's
per-type chart, home.css's badge colors.

Sous-traitant is linkable to field_entree_liee (pays out against a
client's work, like versement/achat); salaire/stage and charges local
pro are not (structural costs, like charge). Turns out 13 lines
already carried "salaire_stage" and 1 "sous_traitant" as raw field
values from the historical migration -- list_string doesn't enforce
allowed_values at the storage level, so they saved fine but had no
label and weren't selectable in the UI until now.

Also fixes a real bug this surfaced: filtering /lignes by a sparse
type (e.g. "Autre") silently broke the sliding window -- so few rows
matched that the table no longer overflowed, so it never fired another
'scroll' event, so loadOlder()/loadNewer() never ran again ("les lignes
antérieures ne chargent plus"). Added ensureScrollable(), which keeps
extending the window in both directions whenever a thinning filter
(compte/client/type/écarts) leaves too little to scroll, and widened
the trim cap while filtering (MAX_LOADED_MONTHS_FILTERED) since the
normal 30-month cap actively fights a sparse filter -- extending one
end and immediately trimming the other nets out to nearly the same
slice every round. Bounded by a round counter rather than "did the
window stop moving": addMonths() uses Date#setMonth(), which isn't
invertible for month-end dates, so the window can drift indefinitely
in tiny steps without ever exactly repeating.

Verified: filtering by "Autre" now finds 24 matches (was stuck at 1)
and the view becomes scrollable within ~17s, settling cleanly rather
than hanging.
2026-09-05 10:26:47 +02:00

245 lines
8.7 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',
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 },
},
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);