- 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>
127 lines
4.8 KiB
JavaScript
127 lines
4.8 KiB
JavaScript
/**
|
|
* @file
|
|
* Aggregate dashboard: solde par compte / solde par client, computed
|
|
* client-side from JSON:API. The line-by-line spreadsheet view is the
|
|
* site's home page (home.js), not this one.
|
|
*/
|
|
(function (Drupal, Vue) {
|
|
'use strict';
|
|
|
|
const API_BASE = '/jsonapi/node/ligne_comptable';
|
|
const EUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' });
|
|
|
|
async function fetchAllLignes() {
|
|
// 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) {
|
|
const res = await fetch(url, { headers: { Accept: 'application/vnd.api+json' } });
|
|
if (!res.ok) throw new Error('JSON:API a répondu ' + res.status);
|
|
const json = await res.json();
|
|
allData.push(...(json.data || []));
|
|
(json.included || []).forEach((item) => includedMap.set(item.type + ':' + item.id, item));
|
|
url = json.links && json.links.next ? json.links.next.href : null;
|
|
}
|
|
// 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) {
|
|
if (!ref) return null;
|
|
return includedMap.get(ref.type + ':' + ref.id) || null;
|
|
}
|
|
|
|
function addTo(map, key, montant) {
|
|
if (!map.has(key)) map.set(key, { entrees: 0, sorties: 0 });
|
|
const row = map.get(key);
|
|
if (montant >= 0) row.entrees += montant;
|
|
else row.sorties += montant;
|
|
}
|
|
|
|
function computeAggregations(data, includedMap) {
|
|
const parComptes = new Map();
|
|
const parClients = new Map();
|
|
for (const node of data) {
|
|
const rels = node.relationships || {};
|
|
const clientTerm = resolve(includedMap, rels.field_client && rels.field_client.data);
|
|
const clientName = clientTerm ? clientTerm.attributes.name : '(sans client)';
|
|
const repartitionRefs = (rels.field_repartition && rels.field_repartition.data) || [];
|
|
for (const ref of repartitionRefs) {
|
|
const paragraph = resolve(includedMap, ref);
|
|
if (!paragraph) continue;
|
|
const montant = parseFloat(paragraph.attributes.field_montant || 0);
|
|
const compteTerm = resolve(includedMap, paragraph.relationships && paragraph.relationships.field_compte && paragraph.relationships.field_compte.data);
|
|
const compteName = compteTerm ? compteTerm.attributes.name : '(compte inconnu)';
|
|
addTo(parComptes, compteName, montant);
|
|
addTo(parClients, clientName, montant);
|
|
}
|
|
}
|
|
return { parComptes, parClients };
|
|
}
|
|
|
|
function mapToRows(map) {
|
|
return Array.from(map.entries())
|
|
.map(([name, v]) => ({ name, entrees: v.entrees, sorties: v.sorties, solde: v.entrees + v.sorties }))
|
|
.sort((a, b) => a.solde - b.solde);
|
|
}
|
|
|
|
function totalsOf(rows) {
|
|
return rows.reduce(
|
|
(acc, r) => ({ entrees: acc.entrees + r.entrees, sorties: acc.sorties + r.sorties, solde: acc.solde + r.solde }),
|
|
{ entrees: 0, sorties: 0, solde: 0 }
|
|
);
|
|
}
|
|
|
|
const App = {
|
|
data() {
|
|
return { loading: true, error: null, tables: [], lineCount: 0 };
|
|
},
|
|
methods: {
|
|
formatEur(v) {
|
|
return EUR.format(v);
|
|
},
|
|
rowClass(solde) {
|
|
if (solde > 0.5) return 'positive';
|
|
if (solde < -0.5) return 'negative';
|
|
return '';
|
|
},
|
|
async load() {
|
|
this.loading = true;
|
|
this.error = null;
|
|
try {
|
|
const { data, includedMap } = await fetchAllLignes();
|
|
this.lineCount = data.length;
|
|
const { parComptes, parClients } = computeAggregations(data, includedMap);
|
|
const comptesRows = mapToRows(parComptes);
|
|
const clientsRows = mapToRows(parClients);
|
|
this.tables = [
|
|
{ title: 'Solde par compte', note: this.lineCount + ' lignes comptables chargées.', rows: comptesRows, totals: totalsOf(comptesRows) },
|
|
{ title: 'Solde par client', note: 'Entrées créditées par client vs. montants sortis (versements, achats, charges) sur les lignes rattachées à ce client.', rows: clientsRows, totals: totalsOf(clientsRows) },
|
|
];
|
|
} catch (err) {
|
|
this.error = err.message;
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
},
|
|
mounted() {
|
|
this.load();
|
|
},
|
|
};
|
|
|
|
Drupal.behaviors.figliComptaDashboard = {
|
|
attach(context) {
|
|
const root = context.querySelector ? context.querySelector('#figli-dashboard-app') : null;
|
|
if (root && !root.dataset.figliInitialized) {
|
|
root.dataset.figliInitialized = '1';
|
|
Vue.createApp(App).mount(root);
|
|
}
|
|
},
|
|
};
|
|
})(Drupal, Vue);
|