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).
This commit is contained in:
2026-09-08 14:34:04 +02:00
parent c927771795
commit 3552b5a79d
3 changed files with 88 additions and 1 deletions
@@ -54,6 +54,12 @@
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() {
@@ -79,7 +85,7 @@
},
},
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-label" :title="item.label">{{ item.label }}</div>' +
'<div class="figli-hbar-track" :class="{\'is-diverging\': hasNegative}">' +
@@ -178,6 +184,36 @@
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);
},
// 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
@@ -82,7 +82,9 @@ class DashboardStatsController extends ControllerBase {
// --- Aggregate the node-level rows in PHP. ---
$caParAnnee = [];
$totalParType = [];
$totalParTypeParAnnee = [];
$caParClient = [];
$caParClientParAnnee = [];
$annees = [];
foreach ($nodeRows as $row) {
$montant = $row->montant_ht !== NULL ? (float) $row->montant_ht : 0.0;
@@ -104,6 +106,17 @@ class DashboardStatsController extends ControllerBase {
$annees[$row->annee] = TRUE;
if ($row->type === 'entree') {
$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;
}
// 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);
@@ -116,6 +129,25 @@ class DashboardStatsController extends ControllerBase {
$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)];
}
}
// --- Aggregate the répartition-level rows in PHP. ---
$soldeParCompte = [];
$soldeParCompteParAnnee = [];
@@ -170,12 +202,19 @@ class DashboardStatsController extends ControllerBase {
// year, which can differ year to year.
ksort($soldeParCompteParAnnee);
ksort($totalParTypeParCompteParAnnee);
ksort($totalParTypeParAnnee);
ksort($topClientsParAnnee);
return new JsonResponse([
'annees' => $anneesList,
'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_par_annee' => array_map(
fn ($parType) => array_map(fn ($v) => round($v, 2), $parType),
$totalParTypeParAnnee
),
'top_clients' => $topClients,
'top_clients_par_annee' => $topClientsParAnnee,
'solde_par_compte' => array_map(fn ($v) => round($v, 2), $soldeParCompte),
'solde_par_compte_par_annee' => $soldeParCompteParAnnee,
'total_par_type_par_compte' => array_map(
@@ -59,12 +59,24 @@
<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>
<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 class="figli-chart-section">
<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>
<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>
</template>
</div>