Add solde totals footer row + fix JSON:API pagination duplicate bug

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 23:04:33 +02:00
co-authored by Claude Sonnet 5
parent 41ab7445f7
commit 2ebfa3b413
4 changed files with 64 additions and 4 deletions
@@ -127,6 +127,26 @@ html.gin--dark-mode #figli-home-app {
z-index: 1; 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 -- /* Error rows: a thin red outline around the row, not a background fill --
easier to read, doesn't fight with dark mode. */ easier to read, doesn't fight with dark mode. */
#figli-home-app tr.figli-error-row td { #figli-home-app tr.figli-error-row td {
@@ -11,7 +11,9 @@
const EUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }); const EUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' });
async function fetchAllLignes() { 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 allData = [];
const includedMap = new Map(); const includedMap = new Map();
while (url) { while (url) {
@@ -22,7 +24,10 @@
(json.included || []).forEach((item) => includedMap.set(item.type + ':' + item.id, item)); (json.included || []).forEach((item) => includedMap.set(item.type + ':' + item.id, item));
url = json.links && json.links.next ? json.links.next.href : null; 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) { function resolve(includedMap, ref) {
@@ -22,7 +22,11 @@
}; };
async function fetchAllLignes() { 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 allData = [];
const includedMap = new Map(); const includedMap = new Map();
while (url) { while (url) {
@@ -33,7 +37,10 @@
(json.included || []).forEach((item) => includedMap.set(item.type + ':' + item.id, item)); (json.included || []).forEach((item) => includedMap.set(item.type + ':' + item.id, item));
url = json.links && json.links.next ? json.links.next.href : null; 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) { function resolve(includedMap, ref) {
@@ -107,6 +114,20 @@
errorCount() { errorCount() {
return this.rows.filter((r) => r.hasError).length; 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() { filteredRows() {
return this.rows.filter((r) => { return this.rows.filter((r) => {
if (this.filterCompte && r.parCompte[this.filterCompte] === undefined) return false; if (this.filterCompte && r.parCompte[this.filterCompte] === undefined) return false;
@@ -151,6 +172,11 @@
typeLabel(t) { typeLabel(t) {
return TYPE_LABELS[t] || 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() { openAddForm() {
Drupal.ajax({ Drupal.ajax({
url: '/node/add/ligne_comptable', url: '/node/add/ligne_comptable',
@@ -86,6 +86,15 @@
</tr> </tr>
</template> </template>
</tbody> </tbody>
<tfoot>
<tr class="figli-totals-row">
<td colspan="4">Solde (créditeur / débiteur) — {{ filteredRows.length }} lignes filtrées</td>
<td class="amount">{{ formatEur(footerTotals.montant_ht) }}</td>
<td class="amount">{{ formatEur(footerTotals.montant_ttc) }}</td>
<td v-for="c in allComptes" :key="c" class="amount compte-col" :class="soldeClass(footerTotals.parCompte[c])">{{ formatEur(footerTotals.parCompte[c]) }}</td>
<td class="amount" :class="soldeClass(footerTotals.ecart)">{{ formatEur(footerTotals.ecart) }}</td>
</tr>
</tfoot>
</table> </table>
</div> </div>
</div> </div>