/** * @file * Dashboard par compte associé (freelance) : un compte à la fois, choisi * dans un menu. L'objectif principal est de faire ressortir clairement les * "versement freelance" pas (encore) compensés par une "entrée client" -- * les autres types de ligne (charge, achat, hébergement, sous-traitant...) * ne rentrent pas dans ce rapprochement, seuls entrée/versement comptent * ici (contrairement à /lignes, où tout type "liable" est concerné). * * Comme dashboard.js : pas de librairie de graphes, tout est fait en * div/CSS (voir dashboard.css) -- un dépendance de plus pour une poignée de * barres n'en vaut pas la peine. * * Le rapprochement entrée/versement reprend exactement l'algorithme déjà * en place dans home.js (reconciliationByEntree) : un versement peut être * lié à plusieurs entrées à la fois (paiement groupé), auquel cas son * montant est réparti à parts égales entre elles. Dupliqué ici plutôt que * factorisé -- home.js et dashboard.js sont déjà deux fichiers autonomes * sans module partagé, donc c'est la convention existante du projet, pas * une entorse. */ (function (Drupal, Vue) { 'use strict'; const API_BASE = '/jsonapi/node/ligne_comptable'; const EUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }); const EUR_ROUND = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }); const MONTHS_SHORT = ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.']; function resolve(includedMap, ref) { if (!ref) return null; return includedMap.get(ref.type + ':' + ref.id) || null; } // Same shape as home.js's buildRows() -- only the two types this page // cares about ever reach it (see fetchEntreesEtVersements()). function buildRows(data, includedMap) { const rows = []; for (const node of data) { const rels = node.relationships || {}; const attrs = node.attributes; const clientTerm = resolve(includedMap, rels.field_client && rels.field_client.data); const entreeLieeRefs = (rels.field_entree_liee && rels.field_entree_liee.data) || []; const entreeLieeIds = entreeLieeRefs.map((ref) => ref.id); const parCompte = {}; 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)'; parCompte[compteName] = (parCompte[compteName] || 0) + montant; } rows.push({ id: node.id, nid: attrs.drupal_internal__nid, date: attrs.field_date_ligne, type: attrs.field_type_ligne, client: clientTerm ? clientTerm.attributes.name : null, libelle: attrs.field_notes || attrs.title, parCompte, entreeLieeIds, }); } rows.sort((a, b) => (a.date || '').localeCompare(b.date || '')); return rows; } // Every entrée/versement, whatever their date -- unlike /lignes there's // no sliding window here: this page needs the *complete* picture to // reconcile a compte's entrées against its versements (one can easily // be paid out a year or more after the other), and entrée+versement // alone is a small enough slice of the ~1500+ line ledger to fetch in // one page load (the rest -- charge/achat/hébergement/sous-traitant/ // autre/ouverture -- is exactly what this page deliberately excludes). async function fetchEntreesEtVersements() { const params = new URLSearchParams(); params.set('include', 'field_repartition,field_repartition.field_compte,field_client,field_entree_liee'); params.set('filter[typeFilter][condition][path]', 'field_type_ligne'); params.set('filter[typeFilter][condition][operator]', 'IN'); params.append('filter[typeFilter][condition][value][]', 'entree'); params.append('filter[typeFilter][condition][value][]', 'versement'); params.set('page[limit]', '50'); params.set('sort', 'field_date_ligne,drupal_internal__nid'); let url = API_BASE + '?' + params.toString(); 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; } const seen = new Set(); const dedup = allData.filter((n) => (seen.has(n.id) ? false : (seen.add(n.id), true))); return buildRows(dedup, includedMap); } // page[limit]=200 would be silently clamped to core's hard cap of 50 -- // same reasoning as fetchClientNames() in home.js -- but with only 8 // comptes, one page always covers all of them; the pagination loop is // kept anyway so this doesn't silently break if the vocabulary grows. async function fetchComptes() { let url = '/jsonapi/taxonomy_term/compte?sort=weight,name&page[limit]=50'; const names = []; 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(); names.push(...(json.data || []).map((t) => t.attributes.name).filter(Boolean)); url = json.links && json.links.next ? json.links.next.href : null; } return names; } // Reuses the existing whole-ledger aggregate endpoint (plain SQL, all // comptes/types/années at once) for the numbers that must reflect the // *true* accounting balance -- solde par compte -- rather than // recomputing a partial one from just entrée+versement rows, which // would silently ignore that compte's charges/achats/etc. async function fetchDashboardStats() { const res = await fetch('/dashboard/api/stats', { headers: { Accept: 'application/json' } }); if (!res.ok) throw new Error('/dashboard/api/stats a répondu ' + res.status); return res.json(); } function readHashCompte() { const params = new URLSearchParams(location.hash.replace(/^#/, '')); return params.get('compte') || ''; } function writeHashCompte(compte) { const params = new URLSearchParams(); if (compte) params.set('compte', compte); const hash = params.toString(); history.replaceState(null, '', location.pathname + location.search + (hash ? '#' + hash : '')); } // Vertical bar(s) per year, diverging from a zero baseline -- shared by // "Évolution du solde" (one bar/year) and "Entrées vs versements par // année" (two bars/year, side by side). A single flexible component // instead of two near-identical ones. const YearBarsChart = { props: { // [{ label, bars: [{ value, color, title }] }] years: { type: Array, required: true }, formatValue: { type: Function, required: true }, }, computed: { maxAbs() { return Math.max(1, ...this.years.flatMap((y) => y.bars.map((b) => Math.abs(b.value)))); }, }, methods: { barStyle(bar) { const pct = (Math.abs(bar.value) / this.maxAbs) * 100; return bar.value >= 0 ? { bottom: '50%', height: pct / 2 + '%', background: bar.color } : { top: '50%', height: pct / 2 + '%', background: bar.color }; }, }, template: '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
{{ y.label }}
' + '
' + '
', }; const HBarChart = { props: { items: { type: Array, required: true }, formatValue: { type: Function, required: true }, }, computed: { maxAbs() { return Math.max(1, ...this.items.map((i) => Math.abs(i.value))); }, }, template: '
' + '
' + '
{{ item.label }}
' + '
' + '
' + '
' + '
{{ formatValue(item.value) }}
' + '
' + '
', }; const App = { components: { YearBarsChart, HBarChart }, data() { return { loading: true, error: null, allRows: [], comptes: [], stats: null, selectedCompte: '', }; }, computed: { sortiesByEntree() { const map = new Map(); for (const r of this.allRows) { for (const entreeId of r.entreeLieeIds) { if (!map.has(entreeId)) map.set(entreeId, []); map.get(entreeId).push(r); } } return map; }, // Same algorithm as home.js's reconciliationByEntree: per entrée, // per compte, résidu = part de l'entrée pour ce compte + part des // versements liés pour ce compte (montants négatifs), un versement // lié à plusieurs entrées voyant son montant réparti à parts égales // entre elles. reconciliationByEntree() { const map = new Map(); for (const entreeRow of this.allRows) { if (entreeRow.type !== 'entree') continue; const linked = this.sortiesByEntree.get(entreeRow.id) || []; const versementsParCompte = {}; for (const s of linked) { const share = s.entreeLieeIds.length || 1; for (const [compte, montant] of Object.entries(s.parCompte)) { versementsParCompte[compte] = (versementsParCompte[compte] || 0) + montant / share; } } const comptes = new Set([...Object.keys(entreeRow.parCompte), ...Object.keys(versementsParCompte)]); const parCompteResidual = {}; for (const c of comptes) { parCompteResidual[c] = Math.round(((entreeRow.parCompte[c] || 0) + (versementsParCompte[c] || 0)) * 100) / 100; } map.set(entreeRow.id, { parCompteResidual }); } return map; }, entreesDuCompte() { if (!this.selectedCompte) return []; return this.allRows.filter((r) => r.type === 'entree' && r.parCompte[this.selectedCompte] !== undefined); }, // Entrées dont la part du compte sélectionné n'est pas (entièrement) // versée -- résidu positif = encore dû, négatif = sur-versé. C'est // le coeur de la page : ce que le collectif doit encore à ce // compte associé, entrée par entrée. resteAVerserRows() { return this.entreesDuCompte .map((entree) => { const recon = this.reconciliationByEntree.get(entree.id); const residual = recon ? (recon.parCompteResidual[this.selectedCompte] ?? entree.parCompte[this.selectedCompte]) : entree.parCompte[this.selectedCompte]; return { entree, montantAttribue: Math.round(entree.parCompte[this.selectedCompte] * 100) / 100, residual, dejaVerse: Math.round((entree.parCompte[this.selectedCompte] - residual) * 100) / 100, }; }) .filter((r) => Math.abs(r.residual) > 0.01) .sort((a, b) => (a.entree.date || '').localeCompare(b.entree.date || '')); }, resteAVerserPositif() { return this.resteAVerserRows.filter((r) => r.residual > 0.01); }, surVerseRows() { return this.resteAVerserRows.filter((r) => r.residual < -0.01); }, // Versements de ce compte qui ne pointent vers aucune entrée du // tout -- ni "reste à verser" ni "sur-versé" ne les couvre (ces // deux listes ne regardent que les *entrées*), donc sans ça un // versement orphelin resterait invisible alors que c'est justement // le genre d'anomalie que cette page doit faire ressortir. versementsNonLies() { if (!this.selectedCompte) return []; return this.allRows .filter((r) => r.type === 'versement' && r.parCompte[this.selectedCompte] !== undefined && r.entreeLieeIds.length === 0) .sort((a, b) => (b.date || '').localeCompare(a.date || '')); }, totalResteAVerser() { return Math.round(this.resteAVerserPositif.reduce((sum, r) => sum + r.residual, 0) * 100) / 100; }, totalSurVerse() { return Math.round(this.surVerseRows.reduce((sum, r) => sum - r.residual, 0) * 100) / 100; }, totalNonLies() { return Math.round(this.versementsNonLies.reduce((sum, r) => sum - (r.parCompte[this.selectedCompte] || 0), 0) * 100) / 100; }, soldeActuel() { if (!this.stats || !this.selectedCompte) return 0; return this.stats.solde_par_compte[this.selectedCompte] || 0; }, // Une seule barre/année, verte au-dessus de zéro / rouge en // dessous -- solde de clôture de ce compte, année par année (même // source que /dashboard, filtrée à ce seul compte). evolutionSoldeYears() { if (!this.stats || !this.selectedCompte) return []; return this.stats.annees.map((y) => { const v = this.stats.solde_par_compte_par_annee[y] ? this.stats.solde_par_compte_par_annee[y][this.selectedCompte] : undefined; const value = v !== undefined ? v : 0; return { label: y, bars: [{ value, color: value >= 0 ? 'var(--figli-positive)' : 'var(--figli-error)', title: 'Solde ' + y }], }; }); }, // Deux barres/année : entrées attribuées à ce compte (vert, vers le // haut) et versements de ce compte (rouge, déjà négatifs -- vers le // bas) -- répond visuellement, année par année, à la question // centrale de cette page. entreeVsVersementYears() { if (!this.stats || !this.selectedCompte) return []; const parAnnee = new Map(); for (const y of this.stats.annees) parAnnee.set(y, { entree: 0, versement: 0 }); for (const r of this.allRows) { const montant = r.parCompte[this.selectedCompte]; if (montant === undefined || !r.date) continue; const y = r.date.slice(0, 4); if (!parAnnee.has(y)) continue; const bucket = parAnnee.get(y); if (r.type === 'entree') bucket.entree += montant; else bucket.versement += montant; } return this.stats.annees.map((y) => { const b = parAnnee.get(y); return { label: y, bars: [ { value: Math.round(b.entree * 100) / 100, color: 'var(--figli-positive)', title: 'Entrées ' + y }, { value: Math.round(b.versement * 100) / 100, color: 'var(--figli-error)', title: 'Versements ' + y }, ], }; }); }, topClientsItems() { const parClient = new Map(); for (const r of this.entreesDuCompte) { const client = r.client || '(sans client)'; parClient.set(client, (parClient.get(client) || 0) + r.parCompte[this.selectedCompte]); } return Array.from(parClient.entries()) .map(([label, value]) => ({ label, value: Math.round(value * 100) / 100 })) .sort((a, b) => b.value - a.value) .slice(0, 10); }, }, methods: { formatEur(v) { return v === null || v === undefined ? '' : EUR.format(v); }, formatEurRound(v) { return EUR_ROUND.format(v); }, formatDate(d) { if (!d) return ''; const parts = d.split('-'); return parts[2] + ' ' + MONTHS_SHORT[parseInt(parts[1], 10) - 1] + ' ' + parts[0]; }, // Ouvre le grand livre déjà filtré sur ce client -- pour aller voir // le détail des lignes plutôt que de dupliquer une vue détaillée // ici. ligneHref(client) { return '/lignes#client=' + encodeURIComponent(client) + '&type=entree,versement'; }, selectCompte(compte) { this.selectedCompte = compte; writeHashCompte(compte); }, async load() { this.loading = true; this.error = null; try { const [allRows, comptes, stats] = await Promise.all([ fetchEntreesEtVersements(), fetchComptes(), fetchDashboardStats(), ]); this.allRows = allRows; this.comptes = comptes; this.stats = stats; const fromHash = readHashCompte(); this.selectedCompte = fromHash && comptes.includes(fromHash) ? fromHash : (comptes[0] || ''); } catch (err) { this.error = err.message; } finally { this.loading = false; } }, }, mounted() { this.load(); }, }; Drupal.behaviors.figliComptaDashboardCompte = { 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);