Compare commits

..
12 Commits
Author SHA1 Message Date
bachir 3688bddba8 Affiche les messages Drupal par-dessus les fenêtres modales
La région [data-drupal-messages] vit dans le layout Gin, où des stacking
contexts ancêtres neutralisaient son position:fixed + z-index : tout le
sous-arbre passait sous l'overlay de la modale jQuery UI (enfant direct
du <body>), rendant illisibles les messages insérés pendant l'édition
d'une ligne (MessageCommand -- erreur de répartition, création...).

admin-chrome.js déplace la région en enfant direct du <body> au
chargement et la re-vérifie dès qu'une modale entre dans le DOM ;
admin-chrome.css passe son z-index à 100000, hors d'atteinte du
_moveToTop de jQuery UI (qui ne remonte un dialog que au-dessus des
siblings .ui-front, ce que la région n'est pas).
2026-09-09 12:59:37 +02:00
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
bachir 74d8eb2aa2 Ajoute "Charges structurelles par client" (+ par année) sur le dashboard SAS
Réutilise le node-level query déjà exécuté pour caParClient/totalParType
(aucune requête SQL supplémentaire) -- accumule abs(montant_ht) des
lignes type=charge par client (le vendeur/organisme : loyer, assurance,
URSSAF...). Trié par montant décroissant (pas alphabétique), pas de
plafond top-N vu le petit nombre de vendeurs distincts. Coloré en gris
"charge", même couleur que ce type dans "Répartition de l'activité par
type".
2026-09-08 15:30:31 +02:00
bachir 96bd9502d2 Les totaux du footer de /lignes suivent maintenant les filtres actifs
Remplace /lignes/api/totaux (LedgerStatsController::totauxAnnee(),
toujours non filtré) par une agrégation côté client des lignes renvoyées
par LedgerRowsController::index() (même endpoint que la fenêtre
glissante) avec annee=<année visible> + les filtres actifs. Reste une
requête serveur dédiée sur l'année entière (pas de LIMIT/range sur la
requête Entity), donc le total ne dépend jamais de ce qui est
effectivement chargé dans la fenêtre glissante à cet instant -- vérifié :
207 lignes non filtrées vs 13 avec le filtre "OVH", total du footer
identique au calcul indépendant dans les deux cas.

onFilterChanged() déclenche maintenant systématiquement
loadCurrentYearTotals() (pas seulement quand detectCurrentYear() détecte
un changement d'année) -- sinon changer un filtre en restant sur la même
année laissait le footer afficher l'ancien total non filtré.

totauxAnnee() et sa route sont supprimés (plus aucun appelant après ce
changement, vérifié par recherche).
2026-09-08 15:25:18 +02:00
bachir cb2a0fcdaa Trie le graph versements par ordre alphabétique, Salaire/stage et Sous-traitant en dernier
Les 6 comptes sont désormais triés alphabétiquement plutôt que par
montant décroissant ; Salaire/stage et Sous-traitant ne participent pas à
ce tri, ils sont simplement ajoutés après coup, dans cet ordre fixe.
2026-09-08 15:10:49 +02:00
bachir 612269ec6d Colore les lignes du graph versements selon leur type
Les 6 comptes reprennent la couleur "versement" (orange, même que
"Versement freelance" dans Répartition par type) puisque c'est la même
somme, juste ventilée par bénéficiaire ; Salaire/stage et Sous-traitant
gardent leur propre couleur de type. Réutilise typeColor()/TYPE_COLORS
déjà en place, juste besoin d'un champ "type" sur chaque item pour que
colorFor puisse s'en servir.
2026-09-08 15:07:41 +02:00
bachir d17a3e2e33 Ajoute Salaire/stage et Sous-traitant au graphique des versements par compte
Deux lignes supplémentaires (pas ventilées par compte, un seul total
chacune) dans "Total des versements par compte" et sa version par année --
même source déjà utilisée par "Répartition de l'activité par type"
(total_par_type / total_par_type_par_annee), aucun changement backend.
2026-09-08 15:05:18 +02:00
bachir 692f7f3ea3 Ajoute "Total des versements par compte" (+ par année) sur le dashboard SAS
Réutilise total_par_type_par_compte / total_par_type_par_compte_par_annee,
déjà exposés par /dashboard/api/stats pour /dashboard/compte -- aucun
changement backend nécessaire, juste extrait la clé "versement" par
compte au lieu de la garder scindée par type.
2026-09-08 15:03:51 +02:00
bachir 8dfb4af98a Nouvelle page /dashboard/repartition, renomme "Dashboard" en "SAS" dans le menu
Nouvelle page "Répartition/Soldes" entre "SAS" (ex-"Dashboard") et "Par
compte" dans le menu : reprend "Solde par compte" et "Évolution du solde
par compte", retirés du dashboard général pour le recentrer sur
l'activité/CA/type/client. Mêmes données déjà exposées par
/dashboard/api/stats (solde_par_compte, solde_par_compte_par_annee),
aucun changement backend nécessaire pour cette page.

js/dashboard-repartition.js reprend le HBarChart/MiniTrend de
dashboard.js -- dupliqués plutôt que partagés, même convention que
dashboard-compte.js. Racine Vue volontairement le même id
#figli-dashboard-app que les deux autres pages dashboard (pas un id
dédié) : dashboard.css scope ses variables CSS (thème clair/sombre) sur
ce sélecteur, réutiliser le même id est comment les trois pages héritent
du même thème sans feuille de style séparée -- vérifié en dark mode.

Le lien de menu "Dashboard" devient "SAS" partout (les 3 templates Twig
+ le nav en render array de HistoryController, qui n'a pas de template
Twig propre).

dashboard.js : MiniTrend/soldeParCompteItems/comptesOrdonnes/
trendValues supprimés (code mort après le déplacement, plus rien ne les
utilise sur le dashboard général).
2026-09-08 15:00:22 +02:00
bachir 43817dcdce Retire la carte "Meilleur solde" du dashboard général 2026-09-08 14:38:23 +02:00
bachir 3552b5a79d Ajoute la répartition par type et le top clients par année sur /dashboard
Reprend exactement le pattern des petits multiples déjà en place sur
/dashboard/compte (grille figli-year-hbar-grid, variante compacte de
h-bar-chart) -- mêmes classes CSS, aucun nouveau style nécessaire.

Backend : DashboardStatsController::stats() calcule maintenant aussi
total_par_type_par_annee et top_clients_par_annee (top 8, contre 12 en
toutes années confondues) à partir des mêmes requêtes SQL déjà en place,
sans requête supplémentaire.

Frontend : dashboard.js n'a jamais de données ligne par ligne (contraire-
ment à dashboard-compte.js qui filtre côté client) -- l'agrégation par
année doit donc venir du serveur. Ajout du prop "compact" au HBarChart de
dashboard.js (jusqu'ici absent, seule la copie de dashboard-compte.js
l'avait).
2026-09-08 14:34:04 +02:00
bachir c927771795 Corrige un deadlock dans onFilterChanged() qui gelait le tableau
ensureScrollable() (appelé par onFilterChanged() après chaque changement
de filtre) appelle lui-même loadOlder()/loadNewer(), qui passent par le
même _queueWindowOp -- en le chaînant *à l'intérieur* de l'opération déjà
mise en file par onFilterChanged(), la file d'attente se retrouvait à
attendre sa propre continuation dès qu'un filtre laissait trop peu de
lignes pour remplir l'écran, gelant purement et simplement le tableau
(recherche qui ne charge plus les lignes précédentes en scrollant, et
même effacer le filtre ensuite ne faisait plus rien -- tout attendait
derrière l'opération bloquée).

Corrigé en chaînant ensureScrollable() après la résolution de l'opération
mise en file, pas dedans -- ses propres appels à loadOlder()/loadNewer()
s'empilent alors normalement sur la file, sans dépendance circulaire.

Reproduit et vérifié en conditions réelles : recherche "Assurance local"
(47 correspondances de 2023 à 2026) qui chargeait bien 2026 mais bloquait
en scrollant vers le haut -- après correctif, chaque scroll vers le haut
déclenche bien un nouveau chargement (vérifié sur 2 scrolls successifs),
et effacer le filtre recharge immédiatement la vue complète.
2026-09-08 14:22:39 +02:00
17 changed files with 635 additions and 188 deletions
@@ -69,7 +69,14 @@ html.gin--dark-mode .figli-page-nav a.is-active {
([data-drupal-messages-fallback], used when Drupal.Message.add() -- our ([data-drupal-messages-fallback], used when Drupal.Message.add() -- our
own MessageCommand-driven AJAX messages included -- has no region to own MessageCommand-driven AJAX messages included -- has no region to
attach to). Auto-dismiss timing for non-error messages is handled in attach to). Auto-dismiss timing for non-error messages is handled in
admin-chrome.js. */ admin-chrome.js.
z-index 100000: the region is lifted to a direct <body> child by
admin-chrome.js (Gin's layout stacking contexts would otherwise bury
it under the modal overlay), and 100000 puts it above the jQuery UI
dialog itself (~100, .ui-front) and its overlay (dialog - 1) -- and
unreachable: jQuery UI only ever raises a dialog above .ui-front
siblings (_moveToTop), which the messages wrapper is not. Messages
stay readable on top of everything while a modal is open. */
[data-drupal-messages], [data-drupal-messages],
[data-drupal-messages-fallback] { [data-drupal-messages-fallback] {
position: fixed !important; position: fixed !important;
@@ -78,7 +85,7 @@ html.gin--dark-mode .figli-page-nav a.is-active {
left: auto !important; left: auto !important;
width: auto; width: auto;
max-width: 22rem; max-width: 22rem;
z-index: 1000; z-index: 100000;
} }
[data-drupal-messages] .messages-list__wrapper, [data-drupal-messages] .messages-list__wrapper,
[data-drupal-messages] .messages__wrapper, [data-drupal-messages] .messages__wrapper,
@@ -364,6 +364,25 @@ html.gin--dark-mode #figli-dashboard-app {
font-size: 0.7rem; font-size: 0.7rem;
} }
/* Total line under an h-bar-chart (currently just Charges structurelles
par client, main chart and each per-année card) -- separate from the
chart component itself, plain right-aligned text matching
.figli-hbar-value's alignment/tabular-nums so the total lines up
visually with the bars' own value column above it. */
#figli-dashboard-app .figli-chart-total {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--figli-border);
text-align: right;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
#figli-dashboard-app .figli-chart-total.is-compact {
margin-top: 0.4rem;
padding-top: 0.4rem;
font-size: 0.72rem;
}
/* --- Year small multiples (par année, next to the all-time chart) --- */ /* --- Year small multiples (par année, next to the all-time chart) --- */
#figli-dashboard-app .figli-year-hbar-grid { #figli-dashboard-app .figli-year-hbar-grid {
display: grid; display: grid;
@@ -25,6 +25,16 @@ dashboard:
- core/drupal - core/drupal
- figli_compta_ledger/vue - figli_compta_ledger/vue
dashboard_repartition:
js:
js/dashboard-repartition.js: {}
css:
theme:
css/dashboard.css: {}
dependencies:
- core/drupal
- figli_compta_ledger/vue
dashboard_compte: dashboard_compte:
js: js:
js/dashboard-compte.js: {} js/dashboard-compte.js: {}
@@ -481,6 +481,10 @@ function figli_compta_ledger_theme($existing, $type, $theme, $path) {
'variables' => ['current_route' => NULL], 'variables' => ['current_route' => NULL],
'template' => 'figli-compta-dashboard', 'template' => 'figli-compta-dashboard',
], ],
'figli_compta_dashboard_repartition' => [
'variables' => ['current_route' => NULL],
'template' => 'figli-compta-dashboard-repartition',
],
'figli_compta_dashboard_compte' => [ 'figli_compta_dashboard_compte' => [
'variables' => ['current_route' => NULL], 'variables' => ['current_route' => NULL],
'template' => 'figli-compta-dashboard-compte', 'template' => 'figli-compta-dashboard-compte',
@@ -503,6 +507,7 @@ function figli_compta_ledger_page_attachments(array &$attachments) {
$front_end_routes = [ $front_end_routes = [
'figli_compta_ledger.home', 'figli_compta_ledger.home',
'figli_compta_ledger.dashboard', 'figli_compta_ledger.dashboard',
'figli_compta_ledger.dashboard_repartition',
'figli_compta_ledger.dashboard_compte', 'figli_compta_ledger.dashboard_compte',
'figli_compta_ledger.history', 'figli_compta_ledger.history',
'figli_compta_ledger.link_entree', 'figli_compta_ledger.link_entree',
@@ -14,6 +14,14 @@ figli_compta_ledger.dashboard:
requirements: requirements:
_permission: 'access content' _permission: 'access content'
figli_compta_ledger.dashboard_repartition:
path: '/dashboard/repartition'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::repartitionView'
_title: 'Répartition / Soldes - SAS Figures Libres'
requirements:
_permission: 'access content'
figli_compta_ledger.dashboard_compte: figli_compta_ledger.dashboard_compte:
path: '/dashboard/compte' path: '/dashboard/compte'
defaults: defaults:
@@ -42,13 +50,6 @@ figli_compta_ledger.link_entree:
node: node:
type: entity:node type: entity:node
figli_compta_ledger.api_totaux_annee:
path: '/lignes/api/totaux'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::totauxAnnee'
requirements:
_permission: 'access content'
figli_compta_ledger.api_annees: figli_compta_ledger.api_annees:
path: '/lignes/api/annees' path: '/lignes/api/annees'
defaults: defaults:
@@ -39,7 +39,32 @@
root.querySelectorAll('.messages-list__item').forEach(handleMessage); root.querySelectorAll('.messages-list__item').forEach(handleMessage);
} }
/**
* Lifts the [data-drupal-messages] region to a direct <body> child.
*
* Gin renders it deep inside its layout (main.page-content > region
* highlighted), where ancestor stacking contexts neutralize the
* region's position:fixed + high z-index (admin-chrome.css): the whole
* subtree then stacks below the jQuery UI modal overlay -- a direct
* <body> child -- which is why messages inserted while a modal is open
* (MessageCommand-driven, e.g. the répartition-sum error on the
* ligne_comptable modal form) rendered unreadably *under* the overlay.
* As a <body> child, the region's z-index competes directly with the
* overlay/dialog and wins. Core itself puts the fallback wrapper at
* body level (Drupal.Message.defaultWrapper(), misc/message.js), so
* this is also where messages already land on a page without the
* region; MessageCommand re-queries the region at response time, so
* moving it after page load breaks no insertion path.
*/
function liftMessages() {
var region = document.querySelector('[data-drupal-messages]');
if (region && region.parentElement !== document.body) {
document.body.appendChild(region);
}
}
function init() { function init() {
liftMessages();
scan(document); scan(document);
new MutationObserver(function (mutations) { new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) { mutations.forEach(function (mutation) {
@@ -48,6 +73,16 @@
if (node.classList && node.classList.contains('messages-list__item')) { if (node.classList && node.classList.contains('messages-list__item')) {
handleMessage(node); handleMessage(node);
} }
// A modal (or a re-rendered messages region) entering the DOM:
// re-check the lift right when it matters -- the region must
// already be body-level when the dialog's overlay and any
// MessageCommand insertions show up. matches() first because
// querySelector() never matches the node itself. liftMessages()
// is idempotent, so over-triggering is harmless.
if (node.matches('.ui-dialog, [data-drupal-messages]')
|| node.querySelector('.ui-dialog, [data-drupal-messages]')) {
liftMessages();
}
if (node.querySelectorAll) { if (node.querySelectorAll) {
scan(node); scan(node);
} }
@@ -0,0 +1,163 @@
/**
* @file
* "Répartition / Soldes": solde par compte (all-time bar chart + one
* year-by-year trend per compte), split out of the general /dashboard so
* that page stays focused on activity/CA/type/client breakdowns. Same
* /dashboard/api/stats endpoint as dashboard.js/dashboard-compte.js
* (DashboardStatsController -- plain SQL GROUP BY, not Entity API), no
* new backend needed -- solde_par_compte/solde_par_compte_par_annee were
* already in that response, just unused on this page until now.
*
* HBarChart/MiniTrend are duplicated from dashboard.js rather than
* shared, same established convention as dashboard-compte.js's own
* copies (see dashboard.css's comment on the Signalement filter rules).
* Root element id is deliberately the same #figli-dashboard-app as the
* other two dashboard pages (not a unique id) -- dashboard.css scopes
* its CSS custom properties (--figli-bg, dark-mode overrides, etc.) to
* that selector, and reusing it is how all three dashboard pages pick
* those up without a separate stylesheet.
*/
(function (Drupal, Vue) {
'use strict';
const EUR_ROUND = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 });
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 -- same shape as dashboard.js's own copy,
// including the zero-centered "diverging" layout for negative values
// (a compte's solde can be a débit).
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>',
};
// Small multiples: one compact zero-centered bar-per-year trend per
// compte, instead of a single 8-series line chart -- same reasoning
// and same markup as dashboard.js's own copy.
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, MiniTrend },
data() {
return { loading: true, error: null, stats: null };
},
computed: {
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);
},
// 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);
},
},
methods: {
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;
});
},
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.figliComptaDashboardRepartition = {
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);
@@ -1,11 +1,12 @@
/** /**
* @file * @file
* Dashboard: charts and aggregate totals (solde par compte, chiffre * Dashboard: charts and aggregate totals (chiffre d'affaires par année,
* d'affaires par année, répartition par type, top clients), computed * répartition par type, top clients), computed server-side
* server-side (DashboardStatsController -- plain SQL GROUP BY, not Entity * (DashboardStatsController -- plain SQL GROUP BY, not Entity API) and
* API) and rendered here as small dependency-free div/CSS bar charts. No * rendered here as small dependency-free div/CSS bar charts. No charting
* charting library: this project vendors its own JS (see js/vendor/), and * library: this project vendors its own JS (see js/vendor/), and a
* a handful of bar/line charts don't warrant pulling one in. * 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) { (function (Drupal, Vue) {
'use strict'; 'use strict';
@@ -54,6 +55,12 @@
items: { type: Array, required: true }, items: { type: Array, required: true },
formatValue: { type: Function, required: true }, formatValue: { type: Function, required: true },
colorFor: { type: Function, default: null }, 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: { computed: {
hasNegative() { hasNegative() {
@@ -79,7 +86,7 @@
}, },
}, },
template: template:
'<div class="figli-hbar-chart">' + '<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-row" v-for="item in items" :key="item.label">' +
'<div class="figli-hbar-label" :title="item.label">{{ item.label }}</div>' + '<div class="figli-hbar-label" :title="item.label">{{ item.label }}</div>' +
'<div class="figli-hbar-track" :class="{\'is-diverging\': hasNegative}">' + '<div class="figli-hbar-track" :class="{\'is-diverging\': hasNegative}">' +
@@ -119,41 +126,8 @@
'</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 = { const App = {
components: { HBarChart, ColumnChart: VBarChart, MiniTrend }, components: { HBarChart, ColumnChart: VBarChart },
data() { data() {
return { loading: true, error: null, stats: null }; return { loading: true, error: null, stats: null };
}, },
@@ -162,12 +136,6 @@
if (!this.stats) return []; if (!this.stats) return [];
return this.stats.annees.map((y) => ({ label: y, value: this.stats.ca_par_annee[y] || 0 })); 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() { typeItems() {
if (!this.stats) return []; if (!this.stats) return [];
return Object.entries(this.stats.total_par_type) return Object.entries(this.stats.total_par_type)
@@ -178,12 +146,105 @@
if (!this.stats) return []; if (!this.stats) return [];
return this.stats.top_clients.map((c) => ({ label: c.client, value: c.ca })); return this.stats.top_clients.map((c) => ({ label: c.client, value: c.ca }));
}, },
// Comptes ordered by all-time solde (richest first) -- same order // Small multiples, one per year -- same source as typeItems() above
// as soldeParCompteItems, so the trend grid below reads as a // (total_par_type_par_annee is the same node-level SQL query, just
// continuation of the bar chart above it rather than an unrelated // also grouped by année, no extra request). Mirrors
// shuffle. // dashboard-compte.js's typeItemsParAnnee, but that one filters a
comptesOrdonnes() { // full row list client-side (it has one, scoped to a single
return this.soldeParCompteItems.map((i) => i.label); // 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() { totalCA() {
if (!this.stats) return 0; if (!this.stats) return 0;
@@ -206,15 +267,17 @@
formatEurRound(v) { formatEurRound(v) {
return EUR_ROUND.format(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) { typeColor(item) {
return TYPE_COLORS[item.type] || '#6b7280'; 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() { async load() {
this.loading = true; this.loading = true;
this.error = null; this.error = null;
@@ -305,10 +305,44 @@
return json; return json;
} }
async function fetchYearTotals(annee) { // Sums the same fields LedgerRowsController::index() rows carry into
const res = await fetch('/lignes/api/totaux?annee=' + encodeURIComponent(annee), { headers: { Accept: 'application/json' } }); // the {montant_ht, cotisation, montant_ttc, ecart, par_compte} shape
if (!res.ok) throw new Error('/lignes/api/totaux a répondu ' + res.status); // the footer template expects.
return res.json(); function aggregateTotals(rows) {
let montantHt = 0;
let cotisation = 0;
let montantTtc = 0;
let ecart = 0;
const parCompte = {};
for (const r of rows) {
montantHt += r.montant_ht || 0;
cotisation += r.cotisation || 0;
montantTtc += r.montant_ttc || 0;
ecart += r.ecart || 0;
for (const [c, v] of Object.entries(r.parCompte)) {
parCompte[c] = (parCompte[c] || 0) + v;
}
}
const round = (v) => Math.round(v * 100) / 100;
return {
montant_ht: round(montantHt),
cotisation: round(cotisation),
montant_ttc: round(montantTtc),
ecart: round(ecart),
par_compte: Object.fromEntries(Object.entries(parCompte).map(([c, v]) => [c, round(v)])),
};
}
// Per-année footer totals, filtered the same way the table itself is --
// fetches every row for the year via the same server-side-filtered
// endpoint the table window uses (fetchFilteredLignes), then sums them
// client-side. Replaces the old /lignes/api/totaux (LedgerStatsController::
// totauxAnnee(), always unfiltered) now that the footer needs to
// reflect whatever's actually on screen, not the whole year regardless
// of the active filters.
async function fetchYearTotals(annee, filters) {
const rows = await fetchFilteredLignes({ annee }, filters);
return Object.assign({ annee }, aggregateTotals(rows));
} }
// URL hash (#compte=Maud,Bachir&type=versement,achat&annee=2023&ecarts=1&aller=2024) // URL hash (#compte=Maud,Bachir&type=versement,achat&annee=2023&ecarts=1&aller=2024)
@@ -1455,14 +1489,33 @@
// Every toolbar filter change routes through here: with filtering // Every toolbar filter change routes through here: with filtering
// now server-side (see fetchFilteredLignes()), there's no more // now server-side (see fetchFilteredLignes()), there's no more
// "just recompute a client-side view" -- the currently loaded // "just recompute a client-side view" -- the currently loaded
// window has to be re-fetched with the new filter applied. Queued // window has to be re-fetched with the new filter applied. The
// through the same chain as loadOlder()/loadNewer() (see // reload itself is queued through the same chain as loadOlder()/
// _queueWindowOp) since several filters can change in the same // loadNewer() (see _queueWindowOp) since several filters can change
// tick (e.g. mounted() restoring them all from the URL hash at // in the same tick (e.g. mounted() restoring them all from the URL
// once), and interleaving their fetches would race on `rows` the // hash at once), and interleaving their fetches would race on
// same way parallel loadOlder()/loadNewer() calls used to. // `rows` the same way parallel loadOlder()/loadNewer() calls used
// to. ensureScrollable() is deliberately chained AFTER that queued
// op settles, not passed into it: ensureScrollable() itself calls
// loadOlder()/loadNewer(), which each enqueue their own op onto the
// very same chain -- queuing it *inside* the op currently occupying
// that chain made the chain await its own continuation (the queued
// op can't finish until its child call, appended behind it on the
// same chain, finishes first) and deadlocked solid the moment a
// filter actually left too few rows to fill the viewport, wedging
// every future filter change and scroll-triggered load right along
// with it.
//
// loadCurrentYearTotals() is called unconditionally here (not just
// left to detectCurrentYear()'s own trigger): that one only
// re-fetches when the *visible year* changes, so a filter change
// while staying on the same year would otherwise leave the footer
// showing stale, pre-filter totals. Independent of the window
// reload above (different endpoint call, no shared state), so no
// need to chain it through the same queue.
onFilterChanged() { onFilterChanged() {
this._queueWindowOp(() => this.reloadWindow().then(() => this.ensureScrollable())); this._queueWindowOp(() => this.reloadWindow()).then(() => this.ensureScrollable());
this.loadCurrentYearTotals();
this.syncHash(); this.syncHash();
}, },
// Keeps extending the window (both directions) as long as a filter // Keeps extending the window (both directions) as long as a filter
@@ -1581,7 +1634,7 @@
if (!this.currentYear) return; if (!this.currentYear) return;
this.currentYearLoading = true; this.currentYearLoading = true;
try { try {
this.currentYearTotals = await fetchYearTotals(this.currentYear); this.currentYearTotals = await fetchYearTotals(this.currentYear, this.currentFilters());
} catch (err) { } catch (err) {
this.currentYearTotals = null; this.currentYearTotals = null;
} finally { } finally {
@@ -40,7 +40,23 @@ class DashboardController extends ControllerBase {
} }
/** /**
* Third page: one compte associé (freelance) at a time -- entrées client * Third page: solde par compte, all-time and year by year -- split out
* of the general /dashboard so that page stays focused on activité/CA/
* type/client rather than per-compte balances. Same
* /dashboard/api/stats data as /dashboard, just a different slice of it.
*/
public function repartitionView() {
return [
'#theme' => 'figli_compta_dashboard_repartition',
'#current_route' => 'figli_compta_ledger.dashboard_repartition',
'#attached' => [
'library' => ['figli_compta_ledger/dashboard_repartition'],
],
];
}
/**
* Fourth page: one compte associé (freelance) at a time -- entrées client
* vs versements freelance, and above all which entrées haven't been * vs versements freelance, and above all which entrées haven't been
* (fully) paid out yet. Complements the aggregate /dashboard above, * (fully) paid out yet. Complements the aggregate /dashboard above,
* which mixes every compte and every type together. * which mixes every compte and every type together.
@@ -60,9 +60,9 @@ class DashboardStatsController extends ControllerBase {
// share, pre-summed per (annee, compte, type) in SQL -- backs solde // share, pre-summed per (annee, compte, type) in SQL -- backs solde
// par compte, both all-time and per-year (each year's own total // par compte, both all-time and per-year (each year's own total
// already includes that year's ouverture line, so it *is* that // already includes that year's ouverture line, so it *is* that
// year's closing balance -- same logic as // year's closing balance -- same logic as home.js's
// LedgerStatsController::totauxAnnee()), and the per-compte type // aggregateTotals()/fetchYearTotals() use for the /lignes footer),
// breakdown used by /dashboard/compte. // and the per-compte type breakdown used by /dashboard/compte.
$compteQuery = $connection->select('node__field_date_ligne', 'd'); $compteQuery = $connection->select('node__field_date_ligne', 'd');
$compteQuery->innerJoin('node__field_type_ligne', 't2', 't2.entity_id = d.entity_id'); $compteQuery->innerJoin('node__field_type_ligne', 't2', 't2.entity_id = d.entity_id');
$compteQuery->innerJoin('node__field_repartition', 'r', 'r.entity_id = d.entity_id'); $compteQuery->innerJoin('node__field_repartition', 'r', 'r.entity_id = d.entity_id');
@@ -82,7 +82,11 @@ class DashboardStatsController extends ControllerBase {
// --- Aggregate the node-level rows in PHP. --- // --- Aggregate the node-level rows in PHP. ---
$caParAnnee = []; $caParAnnee = [];
$totalParType = []; $totalParType = [];
$totalParTypeParAnnee = [];
$caParClient = []; $caParClient = [];
$caParClientParAnnee = [];
$chargeParClient = [];
$chargeParClientParAnnee = [];
$annees = []; $annees = [];
foreach ($nodeRows as $row) { foreach ($nodeRows as $row) {
$montant = $row->montant_ht !== NULL ? (float) $row->montant_ht : 0.0; $montant = $row->montant_ht !== NULL ? (float) $row->montant_ht : 0.0;
@@ -97,6 +101,16 @@ class DashboardStatsController extends ControllerBase {
$client = $row->client ?: '(sans client)'; $client = $row->client ?: '(sans client)';
$caParClient[$client] = ($caParClient[$client] ?? 0) + $montant; $caParClient[$client] = ($caParClient[$client] ?? 0) + $montant;
} }
// Same "who does this money go to" breakdown as $caParClient above,
// but for charges structurelles instead of entrées -- field_client
// on a charge line is the vendor/organisme (loyer, assurance,
// URSSAF...), not a paying client, but it's the same field/same
// taxonomy, so the same grouping applies. Absolute value, same
// convention as $totalParType.
if ($row->type === 'charge') {
$client = $row->client ?: '(sans client)';
$chargeParClient[$client] = ($chargeParClient[$client] ?? 0) + abs($montant);
}
if (!$this->isAnneeValide($row->annee)) { if (!$this->isAnneeValide($row->annee)) {
continue; continue;
@@ -104,6 +118,22 @@ class DashboardStatsController extends ControllerBase {
$annees[$row->annee] = TRUE; $annees[$row->annee] = TRUE;
if ($row->type === 'entree') { if ($row->type === 'entree') {
$caParAnnee[$row->annee] = ($caParAnnee[$row->annee] ?? 0) + $montant; $caParAnnee[$row->annee] = ($caParAnnee[$row->annee] ?? 0) + $montant;
// Per-année équivalent of $caParClient above -- backs the "Top
// clients par année" small multiples, same rows, no extra query.
$client = $row->client ?: '(sans client)';
$caParClientParAnnee[$row->annee][$client] =
($caParClientParAnnee[$row->annee][$client] ?? 0) + $montant;
}
if ($row->type === 'charge') {
$client = $row->client ?: '(sans client)';
$chargeParClientParAnnee[$row->annee][$client] =
($chargeParClientParAnnee[$row->annee][$client] ?? 0) + abs($montant);
}
// Per-année équivalent of $totalParType above -- backs the
// "Répartition de l'activité par type" small multiples.
if ($row->type !== 'ouverture') {
$totalParTypeParAnnee[$row->annee][$row->type] =
($totalParTypeParAnnee[$row->annee][$row->type] ?? 0) + abs($montant);
} }
} }
arsort($caParClient); arsort($caParClient);
@@ -116,6 +146,43 @@ class DashboardStatsController extends ControllerBase {
$topClients[] = ['client' => $client, 'ca' => round($total, 2)]; $topClients[] = ['client' => $client, 'ca' => round($total, 2)];
} }
// Top 8 (not 12 like the all-time chart above) -- one per year keeps
// the small-multiples grid readable, same reasoning as the per-compte
// équivalent on /dashboard/compte (there it's top 5, computed
// client-side from full row data; here it's top 8, computed here
// since dashboard.js only ever gets pre-aggregated SQL, never full
// rows -- see this controller's docblock).
$topClientsParAnnee = [];
foreach ($caParClientParAnnee as $annee => $parClient) {
arsort($parClient);
$topClientsParAnnee[$annee] = [];
$i = 0;
foreach ($parClient as $client => $total) {
if ($i++ >= 8) {
break;
}
$topClientsParAnnee[$annee][] = ['client' => $client, 'ca' => round($total, 2)];
}
}
// No top-N cap here (unlike top clients above) -- structural charges
// only ever go to a handful of recurring vendors (loyer, assurance,
// hébergement, URSSAF...), not the 100+ distinct clients entrées can
// have, so the full list is already short.
arsort($chargeParClient);
$chargeParClientList = [];
foreach ($chargeParClient as $client => $total) {
$chargeParClientList[] = ['client' => $client, 'total' => round($total, 2)];
}
$chargeParClientParAnneeList = [];
foreach ($chargeParClientParAnnee as $annee => $parClient) {
arsort($parClient);
$chargeParClientParAnneeList[$annee] = [];
foreach ($parClient as $client => $total) {
$chargeParClientParAnneeList[$annee][] = ['client' => $client, 'total' => round($total, 2)];
}
}
// --- Aggregate the répartition-level rows in PHP. --- // --- Aggregate the répartition-level rows in PHP. ---
$soldeParCompte = []; $soldeParCompte = [];
$soldeParCompteParAnnee = []; $soldeParCompteParAnnee = [];
@@ -170,12 +237,22 @@ class DashboardStatsController extends ControllerBase {
// year, which can differ year to year. // year, which can differ year to year.
ksort($soldeParCompteParAnnee); ksort($soldeParCompteParAnnee);
ksort($totalParTypeParCompteParAnnee); ksort($totalParTypeParCompteParAnnee);
ksort($totalParTypeParAnnee);
ksort($topClientsParAnnee);
ksort($chargeParClientParAnneeList);
return new JsonResponse([ return new JsonResponse([
'annees' => $anneesList, 'annees' => $anneesList,
'ca_par_annee' => array_map(fn ($v) => round($v, 2), $caParAnnee), 'ca_par_annee' => array_map(fn ($v) => round($v, 2), $caParAnnee),
'total_par_type' => array_map(fn ($v) => round($v, 2), $totalParType), 'total_par_type' => array_map(fn ($v) => round($v, 2), $totalParType),
'total_par_type_par_annee' => array_map(
fn ($parType) => array_map(fn ($v) => round($v, 2), $parType),
$totalParTypeParAnnee
),
'top_clients' => $topClients, 'top_clients' => $topClients,
'top_clients_par_annee' => $topClientsParAnnee,
'charge_par_client' => $chargeParClientList,
'charge_par_client_par_annee' => $chargeParClientParAnneeList,
'solde_par_compte' => array_map(fn ($v) => round($v, 2), $soldeParCompte), 'solde_par_compte' => array_map(fn ($v) => round($v, 2), $soldeParCompte),
'solde_par_compte_par_annee' => $soldeParCompteParAnnee, 'solde_par_compte_par_annee' => $soldeParCompteParAnnee,
'total_par_type_par_compte' => array_map( 'total_par_type_par_compte' => array_map(
@@ -62,10 +62,10 @@ class HistoryController extends ControllerBase {
// pinned next to the page title) -- this controller has no twig // pinned next to the page title) -- this controller has no twig
// template of its own to put a real <nav> in, but the CSS only // template of its own to put a real <nav> in, but the CSS only
// ever targets the class, not the tag, so a render-array // ever targets the class, not the tag, so a render-array
// 'container' (<div>) here looks identical. None of the three // 'container' (<div>) here looks identical. None of the four
// links is ever "active" here since this history feed isn't one // links is ever "active" here since this history feed isn't one
// of them -- same as visiting it from any of the other pages' // of them -- same as visiting it from any of the other pages'
// nav, which doesn't include a 4th "Historique" entry either. // nav, which doesn't include a 5th "Historique" entry either.
'nav' => [ 'nav' => [
'#type' => 'container', '#type' => 'container',
'#attributes' => ['class' => ['figli-page-nav']], '#attributes' => ['class' => ['figli-page-nav']],
@@ -76,9 +76,14 @@ class HistoryController extends ControllerBase {
], ],
'dashboard' => [ 'dashboard' => [
'#type' => 'link', '#type' => 'link',
'#title' => $this->t('Dashboard'), '#title' => $this->t('SAS'),
'#url' => Url::fromRoute('figli_compta_ledger.dashboard'), '#url' => Url::fromRoute('figli_compta_ledger.dashboard'),
], ],
'dashboard_repartition' => [
'#type' => 'link',
'#title' => $this->t('Répartition/Soldes'),
'#url' => Url::fromRoute('figli_compta_ledger.dashboard_repartition'),
],
'dashboard_compte' => [ 'dashboard_compte' => [
'#type' => 'link', '#type' => 'link',
'#title' => $this->t('Par compte'), '#title' => $this->t('Par compte'),
@@ -5,87 +5,20 @@ namespace Drupal\figli_compta_ledger\Controller;
use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Controller\ControllerBase;
use Drupal\node\NodeInterface; use Drupal\node\NodeInterface;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
/** /**
* Small aggregate endpoints backing the /lignes sliding window: the * Small aggregate endpoints backing the /lignes sliding window: the
* row-level JSON:API fetch only ever covers a date range (see home.js), so * row-level fetch only ever covers a date range (see home.js), so the
* neither the totals footer nor the "Année" dropdown can be computed from * "Année" dropdown can't be computed from whatever's currently loaded --
* whatever's currently loaded -- they need their own always-accurate * it needs its own always-accurate query, decoupled from the row window.
* queries, decoupled from the row window. * The footer totals used to live here too (totauxAnnee(), unfiltered)
* until the footer needed to reflect the active toolbar filters -- it's
* now computed client-side in home.js from LedgerRowsController::index()
* rows instead, the same server-side-filtered endpoint the table itself
* uses.
*/ */
class LedgerStatsController extends ControllerBase { class LedgerStatsController extends ControllerBase {
/**
* GET /lignes/api/totaux?annee=2023 -- per-compte répartition sums (plus
* montant HT/TTC/écart totals) for every ligne_comptable dated that
* year, including ouverture lines: the footer is meant to read as the
* actual account balance (solde) for the year, i.e. the same "clôture
* calculée" (ouverture + every movement dated within the year) that
* reconciliationOuverture() compares the *next* year's ouverture
* against. Excluding ouverture here would make this a net-movement
* figure instead, which never matches what the reconciliation badge's
* tooltip cites for the same year.
*/
public function totauxAnnee(Request $request) {
$annee = $request->query->get('annee');
if (!$annee || !preg_match('/^\d{4}$/', $annee)) {
return new JsonResponse(['error' => 'Paramètre "annee" invalide.'], 400);
}
$storage = $this->entityTypeManager()->getStorage('node');
$nids = $storage->getQuery()
->accessCheck(TRUE)
->condition('type', 'ligne_comptable')
->condition('field_date_ligne', $annee . '-01-01', '>=')
->condition('field_date_ligne', ((int) $annee + 1) . '-01-01', '<')
->execute();
$par_compte = [];
$montant_ht = 0.0;
$cotisation = 0.0;
$montant_ttc = 0.0;
$ecart = 0.0;
foreach ($storage->loadMultiple($nids) as $node) {
$ht = $node->hasField('field_montant_ht') && !$node->get('field_montant_ht')->isEmpty()
? (float) $node->get('field_montant_ht')->value : 0.0;
$ttc = $node->hasField('field_montant_ttc') && !$node->get('field_montant_ttc')->isEmpty()
? (float) $node->get('field_montant_ttc')->value : 0.0;
$montant_ht += $ht;
$montant_ttc += $ttc;
// Blank (never 0) for most lines -- only "Entrée client" lines
// with the cotisation checkbox on ever have this field set (see
// figli_compta_ledger_node_presave()) -- but summing a blank
// value as 0 here is exactly right for a total.
if ($node->hasField('field_cotisation_urssaf') && !$node->get('field_cotisation_urssaf')->isEmpty()) {
$cotisation += (float) $node->get('field_cotisation_urssaf')->value;
}
$somme = 0.0;
foreach ($node->get('field_repartition')->referencedEntities() as $paragraph) {
if (!$paragraph->hasField('field_montant') || $paragraph->get('field_montant')->isEmpty()) {
continue;
}
$montant = (float) $paragraph->get('field_montant')->value;
$compte = $paragraph->get('field_compte')->entity ? $paragraph->get('field_compte')->entity->label() : NULL;
if ($compte) {
$par_compte[$compte] = ($par_compte[$compte] ?? 0) + $montant;
}
$somme += $montant;
}
$ecart += round($ht - $somme, 2);
}
return new JsonResponse([
'annee' => $annee,
'montant_ht' => round($montant_ht, 2),
'cotisation' => round($cotisation, 2),
'montant_ttc' => round($montant_ttc, 2),
'ecart' => round($ecart, 2),
'par_compte' => array_map(fn ($v) => round($v, 2), $par_compte),
]);
}
/** /**
* GET /lignes/api/annees -- distinct years, most recent first, with at * GET /lignes/api/annees -- distinct years, most recent first, with at
* least 5 lines. The threshold exists specifically to keep the handful * least 5 lines. The threshold exists specifically to keep the handful
@@ -10,7 +10,8 @@
#} #}
<nav class="figli-page-nav"> <nav class="figli-page-nav">
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a> <a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">Dashboard</a> <a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">SAS</a>
<a href="{{ path('figli_compta_ledger.dashboard_repartition') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_repartition' ? 'is-active' : '' }}">Répartition/Soldes</a>
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a> <a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a>
</nav> </nav>
{% verbatim %} {% verbatim %}
@@ -0,0 +1,39 @@
{#
Répartition / Soldes: solde par compte (all-time + évolution année par
année), split out of the general dashboard (figli-compta-dashboard.html.twig)
to keep that one focused on activité/CA/type/client.
{% verbatim %} below: this is Vue template syntax, not Twig -- both use
{{ }}, so verbatim tells Twig to leave it alone and let Vue compile it
in the browser.
#}
<nav class="figli-page-nav">
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">SAS</a>
<a href="{{ path('figli_compta_ledger.dashboard_repartition') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_repartition' ? 'is-active' : '' }}">Répartition/Soldes</a>
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a>
</nav>
{% verbatim %}
<div id="figli-dashboard-app">
<p v-if="loading">Chargement des données…</p>
<p v-else-if="error" class="figli-error">Erreur de chargement du tableau de bord : {{ error }}</p>
<template v-else>
<section class="figli-chart-section">
<h2>Solde par compte</h2>
<p class="figli-note">Solde cumulé de chaque compte depuis l'origine (ouverture comprise) -- vert = créditeur, rouge = débiteur.</p>
<h-bar-chart :items="soldeParCompteItems" :format-value="formatEurRound"></h-bar-chart>
</section>
<section class="figli-chart-section">
<h2>Évolution du solde par compte</h2>
<p class="figli-note">Solde de clôture de chaque compte, année par année.</p>
<div class="figli-trend-grid">
<div class="figli-trend-card" v-for="compte in comptesOrdonnes" :key="compte">
<div class="figli-trend-title">{{ compte }}</div>
<mini-trend :annees="stats.annees" :values="trendValues(compte)" :format-value="formatEurRound"></mini-trend>
</div>
</div>
</section>
</template>
</div>
{% endverbatim %}
@@ -1,6 +1,7 @@
{# {#
Charts and aggregate totals: solde par compte, chiffre d'affaires par Charts and aggregate totals: chiffre d'affaires par année, répartition
année, répartition par type, top clients. The spreadsheet-like par type, top clients. Solde par compte lives on its own page now (see
figli-compta-dashboard-repartition.html.twig). The spreadsheet-like
line-by-line view is the site's home page (figli-compta-home.html.twig). line-by-line view is the site's home page (figli-compta-home.html.twig).
{% verbatim %} below: this is Vue template syntax, not Twig -- both use {% verbatim %} below: this is Vue template syntax, not Twig -- both use
@@ -9,7 +10,8 @@
#} #}
<nav class="figli-page-nav"> <nav class="figli-page-nav">
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a> <a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">Dashboard</a> <a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">SAS</a>
<a href="{{ path('figli_compta_ledger.dashboard_repartition') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_repartition' ? 'is-active' : '' }}">Répartition/Soldes</a>
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a> <a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a>
</nav> </nav>
{% verbatim %} {% verbatim %}
@@ -26,10 +28,6 @@
<div class="figli-summary-label">CA {{ caAnneeEnCours.annee }}</div> <div class="figli-summary-label">CA {{ caAnneeEnCours.annee }}</div>
<div class="figli-summary-value">{{ formatEurRound(caAnneeEnCours.value) }}</div> <div class="figli-summary-value">{{ formatEurRound(caAnneeEnCours.value) }}</div>
</div> </div>
<div class="figli-summary-card" v-for="item in soldeParCompteItems.slice(0, 1)" :key="'top-' + item.label">
<div class="figli-summary-label">Meilleur solde</div>
<div class="figli-summary-value" :class="item.value < 0 ? 'is-negative' : 'is-positive'">{{ item.label }} · {{ formatEurRound(item.value) }}</div>
</div>
</div> </div>
<section class="figli-chart-section"> <section class="figli-chart-section">
@@ -38,33 +36,54 @@
<column-chart :items="caParAnneeItems" :format-value="formatEurRound"></column-chart> <column-chart :items="caParAnneeItems" :format-value="formatEurRound"></column-chart>
</section> </section>
<section class="figli-chart-section">
<h2>Solde par compte</h2>
<p class="figli-note">Solde cumulé de chaque compte depuis l'origine (ouverture comprise) -- vert = créditeur, rouge = débiteur.</p>
<h-bar-chart :items="soldeParCompteItems" :format-value="formatEurRound"></h-bar-chart>
</section>
<section class="figli-chart-section">
<h2>Évolution du solde par compte</h2>
<p class="figli-note">Solde de clôture de chaque compte, année par année.</p>
<div class="figli-trend-grid">
<div class="figli-trend-card" v-for="compte in comptesOrdonnes" :key="compte">
<div class="figli-trend-title">{{ compte }}</div>
<mini-trend :annees="stats.annees" :values="trendValues(compte)" :format-value="formatEurRound"></mini-trend>
</div>
</div>
</section>
<section class="figli-chart-section"> <section class="figli-chart-section">
<h2>Répartition de l'activité par type</h2> <h2>Répartition de l'activité par type</h2>
<p class="figli-note">Montant total (HT, valeur absolue) par type de ligne, hors ouvertures.</p> <p class="figli-note">Montant total (HT, valeur absolue) par type de ligne, hors ouvertures.</p>
<h-bar-chart :items="typeItems" :format-value="formatEurRound" :color-for="typeColor"></h-bar-chart> <h-bar-chart :items="typeItems" :format-value="formatEurRound" :color-for="typeColor"></h-bar-chart>
<div class="figli-year-hbar-grid">
<div class="figli-year-hbar-card" v-for="y in typeItemsParAnnee" :key="y.annee">
<div class="figli-year-hbar-title">{{ y.annee }}</div>
<h-bar-chart :items="y.items" :format-value="formatEurRound" :color-for="typeColor" compact></h-bar-chart>
</div>
</div>
</section> </section>
<section class="figli-chart-section"> <section class="figli-chart-section">
<h2>Top clients par chiffre d'affaires</h2> <h2>Top clients par chiffre d'affaires</h2>
<p class="figli-note">Les 12 clients ayant généré le plus de chiffre d'affaires, toutes années confondues.</p> <p class="figli-note">Les 12 clients ayant généré le plus de chiffre d'affaires, toutes années confondues.</p>
<h-bar-chart :items="topClientsItems" :format-value="formatEurRound"></h-bar-chart> <h-bar-chart :items="topClientsItems" :format-value="formatEurRound"></h-bar-chart>
<div class="figli-year-hbar-grid">
<div class="figli-year-hbar-card" v-for="y in topClientsParAnnee" :key="y.annee">
<div class="figli-year-hbar-title">{{ y.annee }}</div>
<h-bar-chart :items="y.items" :format-value="formatEurRound" compact></h-bar-chart>
</div>
</div>
</section>
<section class="figli-chart-section">
<h2>Charges structurelles par client</h2>
<p class="figli-note">Montant total (HT, valeur absolue) des charges structurelles par vendeur/organisme, toutes années confondues.</p>
<h-bar-chart :items="chargeParClientItems" :format-value="formatEurRound" :color-for="typeColor"></h-bar-chart>
<div class="figli-chart-total">Total : {{ formatEurRound(sumItems(chargeParClientItems)) }}</div>
<div class="figli-year-hbar-grid">
<div class="figli-year-hbar-card" v-for="y in chargeParClientParAnnee" :key="y.annee">
<div class="figli-year-hbar-title">{{ y.annee }}</div>
<h-bar-chart :items="y.items" :format-value="formatEurRound" :color-for="typeColor" compact></h-bar-chart>
<div class="figli-chart-total is-compact">Total : {{ formatEurRound(sumItems(y.items)) }}</div>
</div>
</div>
</section>
<section class="figli-chart-section">
<h2>Total des versements par compte</h2>
<p class="figli-note">Montant total versé à chaque compte associé, plus les salaires/stages et sous-traitants, toutes années confondues.</p>
<h-bar-chart :items="versementsParCompteItems" :format-value="formatEurRound" :color-for="typeColor"></h-bar-chart>
<div class="figli-year-hbar-grid">
<div class="figli-year-hbar-card" v-for="y in versementsParCompteParAnnee" :key="y.annee">
<div class="figli-year-hbar-title">{{ y.annee }}</div>
<h-bar-chart :items="y.items" :format-value="formatEurRound" :color-for="typeColor" compact></h-bar-chart>
</div>
</div>
</section> </section>
</template> </template>
</div> </div>
@@ -7,7 +7,8 @@
#} #}
<nav class="figli-page-nav"> <nav class="figli-page-nav">
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a> <a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">Dashboard</a> <a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">SAS</a>
<a href="{{ path('figli_compta_ledger.dashboard_repartition') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_repartition' ? 'is-active' : '' }}">Répartition/Soldes</a>
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a> <a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a>
</nav> </nav>
{% verbatim %} {% verbatim %}