/** * @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() { let url = API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client&page[limit]=50'; 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; } return { data: allData, 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);