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:
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user