From 2ebfa3b4138449c12ccca48c9fde268ef779d29b Mon Sep 17 00:00:00 2001 From: bach Date: Thu, 3 Sep 2026 23:04:33 +0200 Subject: [PATCH] Add solde totals footer row + fix JSON:API pagination duplicate bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tfoot row: sum per compte (créditeur/débiteur colored) for the currently filtered rows, plus HT/TTC/écart totals - Fixed a real bug: fetchAllLignes() paginated without a unique sort key (field_date_ligne alone, many ties), which let Drupal's JSON:API return the same row on two pages -- silently inflating totals (Bachir showed -3115,38€ instead of -3013,56€). Now sorts by field_date_ligne,drupal_internal__nid (home) / drupal_internal__nid (dashboard), plus a defensive client-side de-dup by node id either way. Co-Authored-By: Claude Sonnet 5 --- .../custom/figli_compta_ledger/css/home.css | 20 +++++++++++++ .../figli_compta_ledger/js/dashboard.js | 9 ++++-- .../custom/figli_compta_ledger/js/home.js | 30 +++++++++++++++++-- .../templates/figli-compta-home.html.twig | 9 ++++++ 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/web/modules/custom/figli_compta_ledger/css/home.css b/web/modules/custom/figli_compta_ledger/css/home.css index 8ada78f..a48fcfd 100644 --- a/web/modules/custom/figli_compta_ledger/css/home.css +++ b/web/modules/custom/figli_compta_ledger/css/home.css @@ -127,6 +127,26 @@ html.gin--dark-mode #figli-home-app { z-index: 1; } +#figli-home-app tfoot { + position: sticky; + bottom: 0; +} + +#figli-home-app tr.figli-totals-row td { + background: var(--figli-bg-alt); + color: var(--figli-text); + font-weight: 700; + border-top: 2px solid var(--figli-border); + border-bottom: none; +} + +#figli-home-app td.figli-solde-crediteur { + color: var(--figli-positive); +} +#figli-home-app td.figli-solde-debiteur { + color: var(--figli-error); +} + /* Error rows: a thin red outline around the row, not a background fill -- easier to read, doesn't fight with dark mode. */ #figli-home-app tr.figli-error-row td { diff --git a/web/modules/custom/figli_compta_ledger/js/dashboard.js b/web/modules/custom/figli_compta_ledger/js/dashboard.js index 14b0033..4161588 100644 --- a/web/modules/custom/figli_compta_ledger/js/dashboard.js +++ b/web/modules/custom/figli_compta_ledger/js/dashboard.js @@ -11,7 +11,9 @@ const EUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }); async function fetchAllLignes() { - let url = API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client&page[limit]=50'; + // sort by nid: without an explicit, unique sort key, offset pagination + // can silently duplicate or skip rows across pages. + let url = API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client&page[limit]=50&sort=drupal_internal__nid'; const allData = []; const includedMap = new Map(); while (url) { @@ -22,7 +24,10 @@ (json.included || []).forEach((item) => includedMap.set(item.type + ':' + item.id, item)); url = json.links && json.links.next ? json.links.next.href : null; } - return { data: allData, includedMap }; + // Defensive de-dup by node id, in case pagination ever repeats a row. + const seen = new Set(); + const dedup = allData.filter((n) => (seen.has(n.id) ? false : (seen.add(n.id), true))); + return { data: dedup, includedMap }; } function resolve(includedMap, ref) { diff --git a/web/modules/custom/figli_compta_ledger/js/home.js b/web/modules/custom/figli_compta_ledger/js/home.js index 8189cc6..2df1bb3 100644 --- a/web/modules/custom/figli_compta_ledger/js/home.js +++ b/web/modules/custom/figli_compta_ledger/js/home.js @@ -22,7 +22,11 @@ }; async function fetchAllLignes() { - let url = API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client&page[limit]=50&sort=field_date_ligne'; + // sort includes drupal_internal__nid as a tie-breaker: field_date_ligne + // alone is not unique (many lines share a date), and without a unique + // secondary sort key, offset pagination can silently duplicate or skip + // rows across pages. + let url = API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client&page[limit]=50&sort=field_date_ligne,drupal_internal__nid'; const allData = []; const includedMap = new Map(); while (url) { @@ -33,7 +37,10 @@ (json.included || []).forEach((item) => includedMap.set(item.type + ':' + item.id, item)); url = json.links && json.links.next ? json.links.next.href : null; } - return { data: allData, includedMap }; + // Defensive de-dup by node id, in case pagination ever repeats a row. + const seen = new Set(); + const dedup = allData.filter((n) => (seen.has(n.id) ? false : (seen.add(n.id), true))); + return { data: dedup, includedMap }; } function resolve(includedMap, ref) { @@ -107,6 +114,20 @@ errorCount() { return this.rows.filter((r) => r.hasError).length; }, + footerTotals() { + const parCompte = {}; + this.allComptes.forEach((c) => { parCompte[c] = 0; }); + let montantHt = 0, montantTtc = 0, ecart = 0; + for (const r of this.filteredRows) { + montantHt += r.montant_ht || 0; + montantTtc += r.montant_ttc || 0; + ecart += r.ecart || 0; + for (const c of this.allComptes) { + if (r.parCompte[c] !== undefined) parCompte[c] += r.parCompte[c]; + } + } + return { montant_ht: montantHt, montant_ttc: montantTtc, parCompte, ecart }; + }, filteredRows() { return this.rows.filter((r) => { if (this.filterCompte && r.parCompte[this.filterCompte] === undefined) return false; @@ -151,6 +172,11 @@ typeLabel(t) { return TYPE_LABELS[t] || t; }, + soldeClass(v) { + if (v > 0.5) return 'figli-solde-crediteur'; + if (v < -0.5) return 'figli-solde-debiteur'; + return ''; + }, openAddForm() { Drupal.ajax({ url: '/node/add/ligne_comptable', diff --git a/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig b/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig index e47a113..f286115 100644 --- a/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig +++ b/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig @@ -86,6 +86,15 @@ + + + Solde (créditeur / débiteur) — {{ filteredRows.length }} lignes filtrées + {{ formatEur(footerTotals.montant_ht) }} + {{ formatEur(footerTotals.montant_ttc) }} + {{ formatEur(footerTotals.parCompte[c]) }} + {{ formatEur(footerTotals.ecart) }} + +