/** * @file * Progressive decoupling: Drupal renders the page shell (nav, auth via * session cookie, the "Ajouter une ligne" modal form); this Vue app fetches * JSON:API and renders a spreadsheet-like table of every ligne comptable, * with filters, month/year grouping, and per-row écart (répartition sum vs * montant HT) highlighting -- inconsistencies are shown, not hidden. * * Sliding window: with 5+ years of migrated history (~1500+ lines), loading * everything up front took ~40s and made the tab briefly unresponsive. * Instead of one big fetch, `rows` only ever holds a date-range window * (initially the ~18 months around today), extended by ~6 months whenever * the user scrolls near the top or bottom edge (IntersectionObserver on two * sentinel rows), and trimmed from the far end once the window exceeds * MAX_LOADED_MONTHS so it stays a genuine sliding buffer, not an * ever-growing list. The "Année" filter and the totals footer can't be * computed from a partial window, so they're backed by their own small * server endpoints (LedgerStatsController) instead. */ (function (Drupal, Vue, jQuery) { 'use strict'; const API_BASE = '/jsonapi/node/ligne_comptable'; const EUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }); const MONTHS = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']; const TYPE_LABELS = { entree: 'Entrée client', charge: 'Charge structurelle', versement: 'Versement freelance', achat: 'Achat client', hebergement: 'Hébergement', autre: 'Autre', ouverture: 'Ouverture', }; // Sorties that can be linked to the entrée client they pay out against // (field_entree_liee) -- charge/autre/ouverture aren't client-specific. const LINKABLE_TYPES = ['versement', 'achat', 'hebergement']; const WINDOW_MONTHS = 9; // each side of "today", for the initial load const EXTEND_MONTHS = 6; // increment per scroll-triggered load const MAX_LOADED_MONTHS = 30; // trim the far end once the window exceeds this // Absolute bounds on how far the window can extend. Without these, // loadOlder()/loadNewer() kept pushing windowStart/windowEnd outward // even when a fetch came back empty (e.g. extending into future dates // with no data yet) -- since nothing about the table's height or // scroll position changes when 0 rows come back, checkEdges() stayed // satisfied and the next scroll/layout tick fired the exact same load // again, drifting further out forever with no way to stop. Comfortably // before the earliest migrated year (2021) and well past any plausible // future-dated entry. const MIN_LOADABLE_DATE = '2020-06-01'; const MAX_LOADABLE_DATE = addMonths(todayStr(), 24); function todayStr() { return new Date().toISOString().slice(0, 10); } function addMonths(dateStr, n) { const d = new Date(dateStr + 'T00:00:00'); d.setMonth(d.getMonth() + n); return d.toISOString().slice(0, 10); } function monthsBetween(a, b) { const da = new Date(a + 'T00:00:00'); const db = new Date(b + 'T00:00:00'); return (db.getFullYear() - da.getFullYear()) * 12 + (db.getMonth() - da.getMonth()); } // Builds the JSON:API URL for a half-open date range [start, end). function rangeUrl(start, end) { const filter = 'filter[dateRange][group][conjunction]=AND' + '&filter[gte][condition][path]=field_date_ligne' + '&filter[gte][condition][operator]=%3E%3D' + '&filter[gte][condition][value]=' + start + '&filter[gte][condition][memberOf]=dateRange' + '&filter[lt][condition][path]=field_date_ligne' + '&filter[lt][condition][operator]=%3C' + '&filter[lt][condition][value]=' + end + '&filter[lt][condition][memberOf]=dateRange'; return API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client,field_entree_liee' + '&page[limit]=50&sort=field_date_ligne,drupal_internal__nid&' + filter; } async function fetchLignes(url) { // 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. 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 }; } async function fetchClientNames() { const res = await fetch('/jsonapi/taxonomy_term/client?sort=name&page[limit]=200', { headers: { Accept: 'application/vnd.api+json' } }); if (!res.ok) throw new Error('JSON:API a répondu ' + res.status); const json = await res.json(); return (json.data || []).map((t) => t.attributes.name).filter(Boolean).sort(); } async function fetchYearsList() { const res = await fetch('/lignes/api/annees', { headers: { Accept: 'application/json' } }); if (!res.ok) throw new Error('/lignes/api/annees a répondu ' + res.status); const json = await res.json(); return json.annees || []; } async function fetchYearTotals(annee) { const res = await fetch('/lignes/api/totaux?annee=' + encodeURIComponent(annee), { headers: { Accept: 'application/json' } }); if (!res.ok) throw new Error('/lignes/api/totaux a répondu ' + res.status); return res.json(); } function resolve(includedMap, ref) { if (!ref) return null; return includedMap.get(ref.type + ':' + ref.id) || null; } 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 entreeLieeNode = resolve(includedMap, rels.field_entree_liee && rels.field_entree_liee.data); const parCompte = {}; let somme = 0; 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; somme += montant; } const montantHt = attrs.field_montant_ht !== null && attrs.field_montant_ht !== undefined ? parseFloat(attrs.field_montant_ht) : null; const ecart = montantHt !== null ? Math.round((montantHt - somme) * 100) / 100 : 0; 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, montant_ht: montantHt, montant_ttc: attrs.field_montant_ttc !== null && attrs.field_montant_ttc !== undefined ? parseFloat(attrs.field_montant_ttc) : null, parCompte, somme, ecart, hasError: Math.abs(ecart) > 0.01, linkable: LINKABLE_TYPES.includes(attrs.field_type_ligne), entreeLieeId: entreeLieeNode ? entreeLieeNode.id : null, entreeLieeLabel: entreeLieeNode ? (entreeLieeNode.attributes.title || null) : null, }); } rows.sort((a, b) => (a.date || '').localeCompare(b.date || '')); return rows; } const App = { data() { return { loading: true, error: null, rows: [], allComptes: ['Sandrine', 'Maud', 'Ouidade', 'Chloé', 'Bachir', 'Valentin', 'EXT.', 'EXT.WEB'], allClientsList: [], allYearsList: [], filterCompte: '', filterClient: '', filterType: '', filterYear: '', groupBy: 'month', onlyErrors: false, hoverCol: null, filterEntreeId: null, // Fenêtre glissante. windowStart: null, windowEnd: null, loadingOlder: false, loadingNewer: false, // True while EITHER loadOlder() or loadNewer() is running -- they // both mutate `rows`/windowStart/windowEnd, and a scroll-position // compensation write inside one (from trimming the far end) fires // a real 'scroll' event that can kick off the other direction // while the first is still in flight. Without a shared lock, the // two interleave and race on the same state -- observed as `rows` // transiently emptying out (table blinks away) before settling. // loadingOlder/loadingNewer stay separate for the sentinel-row // text; this gates actual execution. loadingWindow: false, // Footer : totaux de l'année actuellement visible à l'écran (pas // de la fenêtre chargée), voir detectCurrentYear(). currentYear: null, currentYearTotals: null, currentYearLoading: false, }; }, computed: { allTypes() { return Object.entries(TYPE_LABELS).map(([value, label]) => ({ value, label })); }, errorCount() { return this.rows.filter((r) => r.hasError).length; }, sortiesByEntree() { const map = new Map(); for (const r of this.rows) { if (!r.entreeLieeId) continue; if (!map.has(r.entreeLieeId)) map.set(r.entreeLieeId, []); map.get(r.entreeLieeId).push(r); } return map; }, // Compares each entrée's répartition (positive shares owed) against // the combined répartition of every sortie linked to it (negative // amounts paid out), per compte -- both sides can be split across // several comptes. A residual near zero means fully settled; // positive means still owed ("reste à verser"); negative means more // was paid out than the entrée allocated ("sur-versé", worth a // closer look). Precomputed once for all entrée rows rather than // recomputed per template read. Limited to the currently loaded // window -- a sortie linked to an entrée outside it won't be // counted (accepted trade-off of the sliding window). reconciliationByEntree() { const map = new Map(); for (const entreeRow of this.rows) { if (entreeRow.type !== 'entree') continue; const linked = this.sortiesByEntree.get(entreeRow.id) || []; const versementsParCompte = {}; for (const s of linked) { for (const [compte, montant] of Object.entries(s.parCompte)) { versementsParCompte[compte] = (versementsParCompte[compte] || 0) + montant; } } const comptes = new Set([...Object.keys(entreeRow.parCompte), ...Object.keys(versementsParCompte)]); let resteAVerser = 0; let surVerse = 0; const detail = []; for (const c of comptes) { const residual = Math.round(((entreeRow.parCompte[c] || 0) + (versementsParCompte[c] || 0)) * 100) / 100; if (residual > 0.01) resteAVerser += residual; else if (residual < -0.01) surVerse += -residual; if (Math.abs(residual) > 0.01) detail.push(c + ' : ' + this.formatEur(residual)); } map.set(entreeRow.id, { count: linked.length, resteAVerser: Math.round(resteAVerser * 100) / 100, surVerse: Math.round(surVerse * 100) / 100, detail: detail.join(', ') || 'Entièrement soldé', }); } return map; }, filteredRows() { // Drill-down mode: an entrée and only the sorties linked to it, // ignoring the other filters -- clicking its badge again clears it. if (this.filterEntreeId) { const entree = this.rows.find((r) => r.id === this.filterEntreeId); const linked = this.sortiesByEntree.get(this.filterEntreeId) || []; return entree ? [entree, ...linked] : linked; } return this.rows.filter((r) => { if (this.filterCompte && r.parCompte[this.filterCompte] === undefined) return false; if (this.filterClient && r.client !== this.filterClient) return false; if (this.filterType && r.type !== this.filterType) return false; if (this.filterYear && (r.date || '').slice(0, 4) !== this.filterYear) return false; if (this.onlyErrors && !r.hasError) return false; return true; }); }, groupedRows() { const list = this.filteredRows; if (this.groupBy === 'none') return list; const groups = new Map(); for (const r of list) { const d = r.date ? new Date(r.date + 'T00:00:00') : null; let key, label; if (this.groupBy === 'year') { key = d ? String(d.getFullYear()) : '?'; label = key; } else { key = d ? d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') : '?'; label = d ? MONTHS[d.getMonth()] + ' ' + d.getFullYear() : 'Date inconnue'; } if (!groups.has(key)) groups.set(key, { label, year: d ? String(d.getFullYear()) : null, rows: [] }); groups.get(key).rows.push(r); } const sortedKeys = Array.from(groups.keys()).sort(); const out = []; for (const key of sortedKeys) { const g = groups.get(key); out.push({ isGroup: true, key: 'g-' + key, label: g.label, count: g.rows.length, year: g.year }); for (const r of g.rows) out.push(Object.assign({ key: 'r-' + r.id }, r)); } return out; }, }, methods: { formatEur(v) { return v === null || v === undefined ? '' : EUR.format(v); }, // jj/mm/aa -- shorter than the API's ISO yyyy-mm-dd, saves column // width in a table already packed with 8 compte columns. formatDate(iso) { if (!iso) return ''; const [y, m, d] = iso.split('-'); return d + '/' + m + '/' + y.slice(2); }, typeLabel(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 ''; }, // Single colored anchor per row (Montant HT only, not every compte // column) so entrées/sorties are scannable at a glance without the // table turning into a red/green garland. No 0.5€ threshold like // soldeClass -- individual lines are often small (e.g. a -1.07€ OVH // renewal), any nonzero sign should read as positive/negative. montantClass(v) { if (v > 0) return 'figli-montant-positif'; if (v < 0) return 'figli-montant-negatif'; return ''; }, openAddForm() { Drupal.ajax({ url: '/node/add/ligne_comptable', dialogType: 'modal', dialog: { width: 800, title: 'Ajouter une ligne comptable' }, progress: { type: 'throbber' }, }).execute(); }, openEditForm(nid) { Drupal.ajax({ url: '/node/' + nid + '/edit', dialogType: 'modal', dialog: { width: 800, title: 'Modifier la ligne comptable' }, progress: { type: 'throbber' }, }).execute(); }, openLinkForm(nid) { Drupal.ajax({ url: '/lignes/' + nid + '/lier', dialogType: 'modal', dialog: { width: 500, title: 'Lier à une entrée client' }, progress: { type: 'throbber' }, }).execute(); }, toggleEntreeFilter(id) { this.filterEntreeId = this.filterEntreeId === id ? null : id; }, onCellHover(evt) { const cell = evt.target.closest('td, th'); if (!cell) return; // No crosshair on the technical action columns (link/edit icons) -- // there's nothing to compare across rows there. if (cell.classList.contains('actions-col')) { this.clearColHover(); return; } // Logical column position, not DOM sibling index: the totals row's // first cell has colspan="4", which shifts every cell.cellIndex // after it out of alignment with the body rows. const index = this.logicalColIndex(cell); if (index === this.hoverCol) return; this.setColHover(index); }, clearColHover() { this.setColHover(null); }, logicalColIndex(cell) { let index = 0; let sib = cell.previousElementSibling; while (sib) { index += sib.colSpan || 1; sib = sib.previousElementSibling; } return index; }, // Whole-column highlight (header + body + footer) to pair with the // row hover: plain DOM class toggling rather than a Vue-bound class // per cell, since the column count/order is fixed markup here, not // data-driven -- no need to thread an index through every