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:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user