Rebuild /dashboard with charts, add Grand livre/Dashboard nav

Nav: a small "Grand livre" / "Dashboard" switcher, top-right on both
pages, with the current page highlighted. Needed hook_theme() to
declare current_route as an accepted variable for both theme hooks --
same class of bug as the earlier can_view_history fix: an undeclared
variable passed via the render array is silently dropped rather than
reaching Twig.

Dashboard: replaced the old "solde par compte / par client" tables
(computed client-side from a full unwindowed JSON:API fetch of every
node -- the same performance problem the /lignes sliding window was
built to avoid, just not yet felt at 1500+ lines) with a single
aggregate endpoint (DashboardStatsController, plain SQL GROUP BY) and
five chart panels: CA par année, solde par compte (diverging,
red/green), solde par compte trend (small multiples per compte),
répartition par type, top clients par CA. No charting library --
small dependency-free div/CSS bar charts (HBarChart/ColumnChart/
MiniTrend components in dashboard.js), consistent with this project
vendoring its own JS.

Two data-correctness fixes along the way: (1) the historical stray
mistyped dates (0213-06-15, 2015-08-29 -- preserved as-is per this
module's policy) needed excluding from per-year buckets without
excluding their money from all-time totals, so the filtering happens
per-output-field in PHP rather than as a blanket SQL date range. (2)
PHP silently casts numeric-looking array keys ("2021") to actual
integers, so array_keys() on a year-keyed map produces a mix of ints
and strings -- json_encode emits the int ones as bare JSON numbers in
a list (unlike object keys, which JSON always stringifies), which
broke the frontend's annees[i].slice(2) trend-card labels. Fixed with
an explicit array_map('strval', ...).

Verified: bar widths/colors match the underlying data exactly (e.g.
EXT.'s red bar is proportionally sized against Maud's green one per
their actual solde ratio), all 8 trend cards render correct year
labels, and both nav links correctly highlight on their own page.
This commit is contained in:
2026-09-04 23:30:40 +02:00
parent 2947a542da
commit 2f9ad7d52c
9 changed files with 659 additions and 139 deletions
@@ -18,6 +18,7 @@ class DashboardController extends ControllerBase {
return [
'#theme' => 'figli_compta_home',
'#can_view_history' => $this->currentUser()->hasPermission('view ligne_comptable revisions'),
'#current_route' => 'figli_compta_ledger.home',
'#attached' => [
'library' => ['figli_compta_ledger/home'],
],
@@ -25,11 +26,13 @@ class DashboardController extends ControllerBase {
}
/**
* Secondary page: aggregate solde par compte / par client.
* Secondary page: charts and aggregate totals (par compte, par année,
* par client, par type).
*/
public function view() {
return [
'#theme' => 'figli_compta_dashboard',
'#current_route' => 'figli_compta_ledger.dashboard',
'#attached' => [
'library' => ['figli_compta_ledger/dashboard'],
],
@@ -0,0 +1,154 @@
<?php
namespace Drupal\figli_compta_ledger\Controller;
use Drupal\Core\Controller\ControllerBase;
use Symfony\Component\HttpFoundation\JsonResponse;
/**
* Single aggregate endpoint backing the charts on /dashboard. Plain SQL
* (Database API), not Entity API -- with 1500+ ligne_comptable nodes,
* loading full entities the way the old dashboard.js did (fetch every node
* via JSON:API, aggregate client-side) is the exact performance problem
* the /lignes sliding window was built to avoid; a handful of GROUP BY
* queries answers every chart in one page load instead.
*/
class DashboardStatsController extends ControllerBase {
/**
* Same reasoning as home.js's MIN_LOADABLE_DATE/MAX_LOADABLE_DATE:
* comfortably before the earliest migrated year (2021) and well past
* any plausible future-dated entry, wide enough to never need updating.
*/
const MIN_ANNEE = '2020';
const MAX_ANNEE = '2030';
/**
* Whether a year is real enough to appear in a per-year chart -- see
* MIN_ANNEE/MAX_ANNEE. Doesn't affect all-time totals, which count
* every line regardless of its date.
*/
private function isAnneeValide(string $annee): bool {
return $annee >= self::MIN_ANNEE && $annee < self::MAX_ANNEE;
}
/**
* GET /dashboard/api/stats.
*/
public function stats() {
$connection = \Drupal::database();
// Node-level aggregate: one row per ligne_comptable (annee, type,
// client, montant_ht) -- backs chiffre d'affaires, per-type, and
// per-client breakdowns. Joining field_repartition here would
// multiply each node by its répartition row count and inflate
// montant_ht sums, so it's deliberately kept separate from the
// répartition-level query below.
$nodeQuery = $connection->select('node__field_date_ligne', 'd');
$nodeQuery->innerJoin('node__field_type_ligne', 't', 't.entity_id = d.entity_id');
$nodeQuery->leftJoin('node__field_client', 'ncl', 'ncl.entity_id = d.entity_id');
$nodeQuery->leftJoin('taxonomy_term_field_data', 'cl', 'cl.tid = ncl.field_client_target_id');
$nodeQuery->leftJoin('node__field_montant_ht', 'mh', 'mh.entity_id = d.entity_id');
$nodeQuery->condition('d.bundle', 'ligne_comptable');
$nodeQuery->addExpression('SUBSTRING(d.field_date_ligne_value, 1, 4)', 'annee');
$nodeQuery->addField('t', 'field_type_ligne_value', 'type');
$nodeQuery->addField('cl', 'name', 'client');
$nodeQuery->addField('mh', 'field_montant_ht_value', 'montant_ht');
$nodeRows = $nodeQuery->execute()->fetchAll();
// Répartition-level aggregate: one row per (node, compte) répartition
// share, pre-summed per (annee, compte) in SQL -- backs solde par
// compte, both all-time and per-year (each year's own total already
// includes that year's ouverture line, so it *is* that year's closing
// balance -- same logic as LedgerStatsController::totauxAnnee()).
$compteQuery = $connection->select('node__field_date_ligne', 'd');
$compteQuery->innerJoin('node__field_repartition', 'r', 'r.entity_id = d.entity_id');
$compteQuery->innerJoin('paragraph__field_montant', 'm', 'm.entity_id = r.field_repartition_target_id');
$compteQuery->innerJoin('paragraph__field_compte', 'c', 'c.entity_id = r.field_repartition_target_id');
$compteQuery->innerJoin('taxonomy_term_field_data', 'tc', 'tc.tid = c.field_compte_target_id');
$compteQuery->condition('d.bundle', 'ligne_comptable');
$compteQuery->addExpression('SUBSTRING(d.field_date_ligne_value, 1, 4)', 'annee');
$compteQuery->addField('tc', 'name', 'compte');
$compteQuery->addExpression('SUM(m.field_montant_value)', 'total');
$compteQuery->groupBy('annee');
$compteQuery->groupBy('compte');
$compteRows = $compteQuery->execute()->fetchAll();
// --- Aggregate the node-level rows in PHP. ---
$caParAnnee = [];
$totalParType = [];
$caParClient = [];
$annees = [];
foreach ($nodeRows as $row) {
$montant = $row->montant_ht !== NULL ? (float) $row->montant_ht : 0.0;
// All-time totals (type breakdown, client ranking) count every line
// regardless of date -- a mistyped date doesn't make the money any
// less real. Only the per-year buckets below need a sane year.
if ($row->type !== 'ouverture') {
$totalParType[$row->type] = ($totalParType[$row->type] ?? 0) + abs($montant);
}
if ($row->type === 'entree') {
$client = $row->client ?: '(sans client)';
$caParClient[$client] = ($caParClient[$client] ?? 0) + $montant;
}
if (!$this->isAnneeValide($row->annee)) {
continue;
}
$annees[$row->annee] = TRUE;
if ($row->type === 'entree') {
$caParAnnee[$row->annee] = ($caParAnnee[$row->annee] ?? 0) + $montant;
}
}
arsort($caParClient);
$topClients = [];
$i = 0;
foreach ($caParClient as $client => $total) {
if ($i++ >= 12) {
break;
}
$topClients[] = ['client' => $client, 'ca' => round($total, 2)];
}
// --- Aggregate the répartition-level rows in PHP. ---
$soldeParCompte = [];
$soldeParCompteParAnnee = [];
foreach ($compteRows as $row) {
$total = (float) $row->total;
// Same reasoning: the all-time balance includes every line; the
// per-year trend only makes sense for a real year.
$soldeParCompte[$row->compte] = ($soldeParCompte[$row->compte] ?? 0) + $total;
if (!$this->isAnneeValide($row->annee)) {
continue;
}
$annees[$row->annee] = TRUE;
$soldeParCompteParAnnee[$row->annee][$row->compte] = round($total, 2);
}
// array_keys() alone would leak PHP's array-key int-casting here: a
// key that looks like a canonical integer ("2021") is silently stored
// as an int, not a string, and json_encode() then emits it as a bare
// JSON number in this *list* -- unlike object keys (ca_par_annee,
// solde_par_compte_par_annee below), which JSON always stringifies
// regardless of the PHP source type. The frontend expects every year
// as a string throughout, so cast explicitly.
$anneesList = array_map('strval', array_keys($annees));
sort($anneesList);
// Chronological order per year, not insertion order -- PHP's array
// key order for soldeParCompteParAnnee follows first-seen compte per
// year, which can differ year to year.
ksort($soldeParCompteParAnnee);
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),
'top_clients' => $topClients,
'solde_par_compte' => array_map(fn ($v) => round($v, 2), $soldeParCompte),
'solde_par_compte_par_annee' => $soldeParCompteParAnnee,
]);
}
}