/** * @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.']; // Same labels/colors as dashboard.js's "Répartition de l'activité par // type" -- duplicated rather than shared, see this file's docblock. const TYPE_LABELS = { entree: 'Entrée client', charge: 'Charge structurelle', versement: 'Versement freelance', achat: 'Achat client', hebergement: 'Hébergement', sous_traitant: 'Sous-traitant', salaire_stage: 'Salaire / stage', charges_local_pro: 'Charges local pro', autre: 'Autre', }; const TYPE_COLORS = { entree: '#1a7f37', charge: '#6b7280', versement: '#d97a0a', achat: '#3b6fe0', hebergement: '#0e9182', sous_traitant: '#c9312b', salaire_stage: '#0891b2', charges_local_pro: '#65a30d', autre: '#9061f0', }; 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 flagRefs = (rels.field_flag && rels.field_flag.data) || []; const flags = flagRefs.map((ref) => resolve(includedMap, ref)).filter(Boolean).map((t) => t.attributes.name); 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, flags, hasFlag: flags.length > 0, }); } 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,field_flag'); 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 }}
' + '
' + '
', }; // Same shape as dashboard.js's HBarChart, colorFor included (used by // the type-breakdown chart; Top clients below just omits it and gets // the plain positive-green default). const HBarChart = { props: { items: { type: Array, required: true }, formatValue: { type: Function, required: true }, colorFor: { type: Function, default: null }, // Narrower label/value columns, smaller text -- for the per-année // small-multiples grids, where a full-width chart wouldn't fit in // a grid card. compact: { type: Boolean, default: false }, }, computed: { maxAbs() { return Math.max(1, ...this.items.map((i) => Math.abs(i.value))); }, }, methods: { fillColor(item) { return this.colorFor ? this.colorFor(item) : 'var(--figli-positive)'; }, }, template: '
' + '
' + '
{{ item.label }}
' + '
' + '
' + '
' + '
{{ formatValue(item.value) }}
' + '
' + '
', }; const App = { components: { YearBarsChart, HBarChart }, data() { return { loading: true, error: null, allRows: [], comptes: [], stats: null, selectedCompte: '', // Filtre "Lignes signalées" -- même modèle multi-valeurs/OR que // sur /lignes. Propre à ce compte (voir selectCompte()). flagFilter: [], }; }, 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 || '')); }, // Toute ligne entrée/versement de ce compte portant un signalement // (voir /lignes), qu'elle apparaisse déjà dans une des listes // ci-dessus ou non -- une ligne entièrement soldée peut quand même // porter un problème sans rapport avec le montant (ex. "client // injoignable"), auquel cas aucune des trois listes ci-dessus ne // la montrerait autrement. lignesSignalees() { if (!this.selectedCompte) return []; return this.allRows .filter((r) => r.hasFlag && r.parCompte[this.selectedCompte] !== undefined) .sort((a, b) => (b.date || '').localeCompare(a.date || '')); }, // Tags réellement présents parmi les lignes signalées de ce compte // -- dérivé de lignesSignalees (pas de lignesSignaleesFiltrees), pour // que la liste d'options du filtre reste stable même une fois un tag // sélectionné (sinon les autres tags disparaîtraient du menu dès // qu'on en coche un). flagsDisponibles() { return Array.from(new Set(this.lignesSignalees.flatMap((r) => r.flags))).sort(); }, // Même modèle "plusieurs valeurs, sémantique OR" que le filtre Type // sur /lignes -- une ligne ressort si elle porte au moins un des // tags cochés. lignesSignaleesFiltrees() { if (!this.flagFilter.length) return this.lignesSignalees; return this.lignesSignalees.filter((r) => this.flagFilter.some((f) => r.flags.includes(f))); }, // Solde net des lignes signalées (filtrées) elles-mêmes (entrées // reçues moins versements sortis, pour ce compte) -- pas un total // "reste dû" comme totalResteAVerser ci-dessous, juste la somme des // montants affichés dans le tableau, pour avoir une idée de // l'ampleur de ce qui est signalé (ou de ce sous-ensemble de tags). totalLignesSignalees() { return Math.round(this.lignesSignaleesFiltrees.reduce((sum, r) => sum + r.parCompte[this.selectedCompte], 0) * 100) / 100; }, 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); }, // Same chart as /dashboard's "Répartition de l'activité par type", // filtered to this compte -- unlike the reconciliation tables above // (deliberately entrée/versement only, see this file's docblock), // this comes straight from /dashboard/api/stats's per-compte // breakdown, so it covers every type touching this compte's // répartition (charge, achat, hébergement...), matching what the // general dashboard shows for the whole ledger. typeItems() { if (!this.stats || !this.selectedCompte) return []; const parType = this.stats.total_par_type_par_compte[this.selectedCompte] || {}; return Object.entries(parType) .map(([type, value]) => ({ label: TYPE_LABELS[type] || type, value, type })) .sort((a, b) => b.value - a.value); }, // Small multiples, one per year -- same source as typeItems() above // (total_par_type_par_compte_par_annee is the same répartition-level // SQL query, just also grouped by année, no extra request). typeItemsParAnnee() { if (!this.stats || !this.selectedCompte) return []; return this.stats.annees.map((annee) => { const parType = (this.stats.total_par_type_par_compte_par_annee[annee] || {})[this.selectedCompte] || {}; const items = Object.entries(parType) .map(([type, value]) => ({ label: TYPE_LABELS[type] || type, value, type })) .sort((a, b) => b.value - a.value); return { annee, items }; }).filter((y) => y.items.length); }, // Top 5 (not 10 like the all-time chart, above) -- one per year // keeps the small-multiples grid readable. topClientsParAnnee() { if (!this.selectedCompte) return []; return this.stats.annees.map((annee) => { const parClient = new Map(); for (const r of this.entreesDuCompte) { if ((r.date || '').slice(0, 4) !== annee) continue; const client = r.client || '(sans client)'; parClient.set(client, (parClient.get(client) || 0) + r.parCompte[this.selectedCompte]); } const items = Array.from(parClient.entries()) .map(([label, value]) => ({ label, value: Math.round(value * 100) / 100 })) .sort((a, b) => b.value - a.value) .slice(0, 5); return { annee, items }; }).filter((y) => y.items.length); }, }, methods: { formatEur(v) { return v === null || v === undefined ? '' : EUR.format(v); }, formatEurRound(v) { return EUR_ROUND.format(v); }, typeColor(item) { return TYPE_COLORS[item.type] || '#6b7280'; }, 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; // A tag selected for one compte may not even exist for the next // one -- flagsDisponibles() would just drop it from the visible // options while leaving it silently active in flagFilter. this.flagFilter = []; 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);