/** * @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', sous_traitant: 'Sous-traitant', salaire_stage: 'Salaire / stage', charges_local_pro: 'Charges local pro', autre: 'Autre', ouverture: 'Ouverture', }; // Sorties that can be linked to the entrée client they pay out against // (field_entree_liee) -- charge/autre/ouverture/salaire_stage/ // charges_local_pro aren't client-specific. const LINKABLE_TYPES = ['versement', 'achat', 'hebergement', 'sous_traitant']; 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 // While a thinning filter (compte/client/type/écarts) is active, the cap // above is actively counter-productive: extending one end and trimming // the other nets out to roughly the same 30-month slice every round // (see ensureScrollable()), so a filter with only a handful of matches // per year can search almost forever without ever surfacing more of // them. Comfortably wider than the whole migrated dataset (2021-today) // so a filter effectively gets the entire history to search, still // bounded (not literally unbounded memory) -- and DOM rendering cost // stays proportional to filteredRows, not this raw fetched count. const MAX_LOADED_MONTHS_FILTERED = 96; // Hard cap on ensureScrollable()'s recursion (see below) -- comfortably // more than enough rounds to walk the entire MIN_LOADABLE_DATE.. // MAX_LOADABLE_DATE span at EXTEND_MONTHS per round. const MAX_ENSURE_SCROLLABLE_ROUNDS = 30; // 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 || []; } // { "2023": { "Bachir": -0.03, ... }, ... } -- years where the actual // ouverture doesn't match the previous year's calculated closing // balance (that year's own ouverture + every movement dated within // it). Small dataset (a handful of year boundaries), fetched once. async function fetchOuvertureEcarts() { const res = await fetch('/lignes/api/reconciliation-ouverture', { headers: { Accept: 'application/json' } }); if (!res.ok) throw new Error('/lignes/api/reconciliation-ouverture a répondu ' + res.status); return res.json(); } // POST /lignes/{nid}/type -- change field_type_ligne without opening the // full edit form. Fetches a fresh CSRF token each time (core's // /session/token) rather than caching one -- this is an occasional // action, not worth the complexity of handling a stale cached token. async function updateLigneType(nid, type) { const tokenRes = await fetch('/session/token'); const token = await tokenRes.text(); const res = await fetch('/lignes/' + nid + '/type', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token }, body: JSON.stringify({ type }), }); const json = await res.json().catch(() => ({})); if (!res.ok) throw new Error(json.error || ('/lignes/' + nid + '/type a répondu ' + res.status)); return json; } 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(); } // URL hash (#compte=Maud&type=versement&annee=2023&ecarts=1&aller=2024) // makes the filters and "Aller à" shareable/reloadable -- reload the // page or send the link and you land back in the same filtered view. // "aller" isn't an ongoing filter (jumpYearValue itself always resets // to '' right after firing) but its target year is worth remembering // for this purpose, so it's tracked separately (lastJumpYear) purely // for the hash. Not included: groupBy, filterEntreeId -- out of scope // of what was asked (the toolbar filters + "Aller à"). function readHashState() { const params = new URLSearchParams(location.hash.replace(/^#/, '')); return { filterCompte: params.get('compte') || '', filterClient: params.get('client') || '', filterType: params.get('type') || '', filterYear: params.get('annee') || '', onlyErrors: params.get('ecarts') === '1', aller: params.get('aller') || '', }; } function buildHashString(state) { const params = new URLSearchParams(); if (state.filterCompte) params.set('compte', state.filterCompte); if (state.filterClient) params.set('client', state.filterClient); if (state.filterType) params.set('type', state.filterType); if (state.filterYear) params.set('annee', state.filterYear); if (state.onlyErrors) params.set('ecarts', '1'); // Redundant/ambiguous alongside an active "Année" filter -- that // already fully describes the year, so don't also carry a stale // "aller" target into the hash. if (state.lastJumpYear && !state.filterYear) params.set('aller', state.lastJumpYear); return params.toString(); } 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); // field_entree_liee is multi-value (a single payment sometimes // covers several client invoices at once) -- JSON:API always // returns an array for a multi-cardinality relationship, one ref // per linked entrée, even when there's only one or none. const entreeLieeRefs = (rels.field_entree_liee && rels.field_entree_liee.data) || []; const entreeLieeNodes = entreeLieeRefs.map((ref) => resolve(includedMap, ref)).filter(Boolean); 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), entreeLieeIds: entreeLieeNodes.map((n) => n.id), entreeLieeLabels: entreeLieeNodes.map((n) => n.attributes.title || n.id), }); } 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: [], ouvertureEcarts: {}, filterCompte: '', filterClient: '', filterType: '', filterYear: '', jumpYearValue: '', // Not a filter itself (jumpYearValue always resets to '' right // after firing) -- just remembers the last "Aller à" target so // the URL hash can reflect it. See syncHash()/readHashState(). lastJumpYear: '', groupBy: 'month', onlyErrors: false, hoverCol: null, filterEntreeId: null, // Which row's type badge is currently showing its inline