/** * @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 }; } // GET /lignes/api/groupe/{nid} -- the full transitive closure of node // ids connected via field_entree_liee (both directions), resolved // server-side since the sliding window can't discover it client-side // (see LedgerStatsController::groupeEntree()). async function fetchGroupeIds(nid) { const res = await fetch('/lignes/api/groupe/' + nid, { headers: { Accept: 'application/json' } }); if (!res.ok) throw new Error('/lignes/api/groupe a répondu ' + res.status); const json = await res.json(); return json.nids || []; } // Fetches full ligne data (same includes as the sliding window) for a // specific set of node ids, regardless of date -- used to fill in // whichever entrée/sortie nodes fetchGroupeIds() found that aren't in // the currently loaded window. async function fetchLignesByNids(nids) { if (!nids.length) return { data: [], includedMap: new Map() }; const params = new URLSearchParams(); params.set('include', 'field_repartition,field_repartition.field_compte,field_client,field_entree_liee'); params.set('filter[parNid][condition][path]', 'drupal_internal__nid'); params.set('filter[parNid][condition][operator]', 'IN'); nids.forEach((nid) => params.append('filter[parNid][condition][value][]', nid)); return fetchLignes(API_BASE + '?' + params.toString()); } async function fetchClientNames() { // page[limit]=200 is silently clamped to core's hard cap of 50 by // JSON:API (Query\OffsetPage::SIZE_MAX) -- with 106 client terms, // that cut off everything past the 50th alphabetically (e.g. "LE // CAMPUS") unless every page is followed, same as fetchLignes() above. let url = '/jsonapi/taxonomy_term/client?sort=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.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,Bachir&type=versement,achat&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. // compte/type can each hold several values (comma-separated) since // both filters are now , so ctrl/cmd+click // and shift+click just work (Vue's v-model binds a // instead of the badge -- only one at a time. editingTypeId: null, typeUpdateError: 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; }, // Every row known locally that could feed the reconciliation/ // drill-down computeds below: the loaded sliding window, plus // whatever groupExtraRows filled in (see toggleEntreeFilter()) -- // deduped by id in case the same node ends up in both. A versement // can link to (or be linked from) an entrée dated years earlier or // later, well outside `rows`; groupExtraRows is how that gap gets // closed once the full group has been resolved server-side. allKnownRows() { if (!this.groupExtraRows.length) return this.rows; const seen = new Set(this.rows.map((r) => r.id)); return [...this.rows, ...this.groupExtraRows.filter((r) => !seen.has(r.id))]; }, // A sortie linked to several entrées (one payment covering several // invoices) appears under each of them here. sortiesByEntree() { const map = new Map(); for (const r of this.allKnownRows) { for (const entreeId of r.entreeLieeIds) { if (!map.has(entreeId)) map.set(entreeId, []); map.get(entreeId).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. Only sees whatever sortiesByEntree // knows about -- accurate for an entrée once its full group has // been resolved (see allKnownRows()/toggleEntreeFilter()), but may // under-count a sortie linked to it that's neither in the loaded // window nor already fetched into groupExtraRows. // // A sortie linked to several entrées at once (one payment covering // several invoices) has no record of how much of it applies to // each -- there's no per-link amount, just a set of linked entrées. // Split its répartition equally between them as the least-wrong // assumption available, rather than counting its full amount // against every linked entrée (which would double- or triple-count // the same money). reconciliationByEntree() { const map = new Map(); for (const entreeRow of this.allKnownRows) { 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)]); let resteAVerser = 0; let surVerse = 0; const detail = []; // Kept per-compte (not just folded into the two totals above) -- // linkStatus() below needs to check a single sortie's own // compte(s) against the entrée, not the entrée's overall // reconciliation, which can span *other* comptes tied to other // sorties linked to the same entrée. const parCompteResidual = {}; for (const c of comptes) { const residual = Math.round(((entreeRow.parCompte[c] || 0) + (versementsParCompte[c] || 0)) * 100) / 100; parCompteResidual[c] = residual; 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é', parCompteResidual, }); } return map; }, // The full connected group of entrées + sorties reachable from // filterEntreeId by following field_entree_liee links transitively. // A sortie can now link to several entrées at once (split payment), // so drilling into one entrée should also surface every *other* // entrée it shares a sortie with, and that entrée's own sorties in // turn -- not just the originally-clicked entrée's direct links. // Runs over allKnownRows (loaded window + groupExtraRows), not just // `rows` -- toggleEntreeFilter() fetches the complete group up // front via /lignes/api/groupe/{node}, so by the time this // re-evaluates, every member should already be present regardless // of its date. filterEntreeGroup() { if (!this.filterEntreeId) return null; const pool = this.allKnownRows; const entreeIds = new Set([this.filterEntreeId]); let grown = true; while (grown) { grown = false; for (const r of pool) { if (r.type === 'entree' || !r.entreeLieeIds.some((id) => entreeIds.has(id))) continue; for (const id of r.entreeLieeIds) { if (!entreeIds.has(id)) { entreeIds.add(id); grown = true; } } } } const entrees = pool.filter((r) => r.type === 'entree' && entreeIds.has(r.id)); const sorties = pool.filter((r) => r.type !== 'entree' && r.entreeLieeIds.some((id) => entreeIds.has(id))); return [...entrees, ...sorties]; }, // Which comptes actually carry a value somewhere in the drill-down // group -- unlike the main table (always all 8, so columns line up // across every row/year), this modal's group is usually 2-4 rows // touching only 1-2 comptes, so the other 6+ empty columns are pure // clutter. modalComptes() { if (!this.filterEntreeGroup) return []; return this.allComptes.filter((c) => this.filterEntreeGroup.some((r) => r.parCompte[c] !== undefined)); }, filteredRows() { return this.rows.filter((r) => { // Several selected comptes/types match with OR semantics ("any // of these") -- a line either touches one of the selected // comptes or it doesn't, same idea for type. if (this.filterCompte.length && !this.filterCompte.some((c) => r.parCompte[c] !== undefined)) return false; if (this.filterClient && r.client !== this.filterClient) return false; if (this.filterType.length && !this.filterType.includes(r.type)) return false; if (this.filterYear && (r.date || '').slice(0, 4) !== this.filterYear) return false; if (this.onlyErrors && !r.hasError) return false; return true; }); }, // Totals for the entrée + linked sorties drill-down modal (see // filterEntreeGroup above): the whole point of that view is "does // this entrée balance against what was paid out", so its own footer // answers exactly that instead of the current year's totals -- and // it can be computed locally (unlike the per-year figures, this // handful of rows is already fully loaded), no server round-trip // needed. Same shape as /lignes/api/totaux so the template can // render either the same way. drilldownTotals() { if (!this.filterEntreeId) return null; let montantHt = 0; let montantTtc = 0; let ecart = 0; const parCompte = {}; for (const r of this.filterEntreeGroup) { montantHt += r.montant_ht || 0; montantTtc += r.montant_ttc || 0; ecart += r.ecart || 0; for (const [c, v] of Object.entries(r.parCompte)) { parCompte[c] = (parCompte[c] || 0) + v; } } const round = (v) => Math.round(v * 100) / 100; return { montant_ht: round(montantHt), montant_ttc: round(montantTtc), ecart: round(ecart), par_compte: Object.fromEntries(Object.entries(parCompte).map(([c, v]) => [c, round(v)])), }; }, 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); }, // Écart between this ouverture row's year and the previous year's // calculated closing balance -- restricted to whichever compte(s) // this specific row's répartition actually touches (ouverture lines // are entered one per compte, so showing every compte's écart on // every row would just repeat the same list). Null if this row's // compte(s) reconcile cleanly. ouvertureEcart(item) { if (item.type !== 'ouverture' || !item.date) return null; const annee = item.date.slice(0, 4); const ecarts = this.ouvertureEcarts[annee]; if (!ecarts) return null; const relevant = Object.keys(item.parCompte).filter((c) => ecarts[c] !== undefined); if (!relevant.length) return null; const detail = relevant.map((compte) => compte + ' : ' + this.formatEur(ecarts[compte])).join(', '); return { detail, comptes: relevant.length }; }, // Status badge for any linkable sortie row (versement, achat, // hébergement, sous-traitant -- see LINKABLE_TYPES) -- always // present for one of those types, specifically so a // linked-but-settled row still gets a badge to drill down through // (it used to return null there, silently losing the only way to // open the linked entrée's filtered view for a row with nothing // wrong to report). "kind" distinguishes not-linked-at-all, a // residual against at least one linked entrée, or linked-and-settled: // - not linked at all // - linked but its own compte(s) still show a residual against at // least one linked entrée -- deliberately scoped to just the // compte(s) this row's own répartition touches // (reconciliationByEntree's parCompteResidual), not the // entrée's overall resteAVerser/surVerse, which can be driven // entirely by a *different* compte tied to some other sortie // linked to the same entrée and would say nothing about // whether this row's own répartition is settled. When linked // to several entrées (see reconciliationByEntree's equal-split // note), each entrée's residual for these compte(s) counts // separately -- they're independent invoices, each with its // own outstanding amount. // - linked and settled // - inconnu: at least one linked entrée's reconciliation couldn't // be resolved (not yet fetched into groupExtraRows -- see // allKnownRows()/loadEntreeGroup()). Deliberately distinct from // "ok": defaulting an unresolved entrée to "settled" would show // a false all-clear for a row that's actually fine, or one // that owes money, purely because its linked entrée hasn't // been fetched yet -- opening the badge resolves it and flips // the status to whatever it actually is. linkStatus(item) { if (!LINKABLE_TYPES.includes(item.type)) return null; if (!item.entreeLieeIds.length) { return { kind: 'non-liee', detail: 'Aucune entrée client liée.' }; } let resteAVerser = 0; let surVerse = 0; let unresolved = false; for (const entreeId of item.entreeLieeIds) { const recon = this.reconciliationByEntree.get(entreeId); if (!recon) { unresolved = true; continue; } for (const c of Object.keys(item.parCompte)) { const residual = recon.parCompteResidual[c] || 0; if (residual > 0.01) resteAVerser += residual; else if (residual < -0.01) surVerse += -residual; } } if (resteAVerser > 0.01) { return { kind: 'reste', detail: 'Reste à verser (comptes de cette ligne) : ' + this.formatEur(Math.round(resteAVerser * 100) / 100) }; } if (surVerse > 0.01) { return { kind: 'sur-verse', detail: 'Sur-versé (comptes de cette ligne) : ' + this.formatEur(Math.round(surVerse * 100) / 100) }; } if (unresolved) { return { kind: 'inconnu', detail: 'Entrée(s) liée(s) pas encore vérifiée(s) -- cliquer pour vérifier.' }; } const n = item.entreeLieeIds.length; return { kind: 'ok', detail: 'Lié à ' + n + ' entrée' + (n > 1 ? 's' : '') + ' client' + (n > 1 ? 's' : '') + '.' }; }, linkStatusLabel(kind) { if (kind === 'non-liee') return 'Non liée'; if (kind === 'reste') return 'Reste à verser'; if (kind === 'sur-verse') return 'Sur-versé'; if (kind === 'inconnu') return 'À vérifier'; return 'Lié'; }, // Only non-liee/sur-versé read as a hard anomaly (red); reste and // inconnu are their own softer amber; ok gets neither, falling back // to the badge's default green -- same "all clear" green the // entrée side already uses for a fully-settled "N sorties liées". linkStatusClasses(kind) { return { 'is-anomalie': kind === 'non-liee' || kind === 'sur-verse', 'is-reste': kind === 'reste' || kind === 'inconnu', }; }, // 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(); }, // Opens/closes the entrée + linked sorties drill-down modal -- a // separate overlay (see the template), not a filter applied to the // main table, so opening/closing it never touches the main table's // scroll position. toggleEntreeFilter(id) { if (this.filterEntreeId === id) { this.closeDrilldown(); return; } this.filterEntreeId = id; this.loadEntreeGroup(id); }, closeDrilldown() { this.filterEntreeId = null; }, // Resolves the full transitive-closure group for the given row id // via /lignes/api/groupe/{nid} (the sliding window can't discover // it client-side -- see LedgerStatsController::groupeEntree()), and // fetches full ligne data for whatever member isn't already known // locally, merging it into groupExtraRows. filterEntreeGroup/ // reconciliationByEntree re-evaluate automatically once that // lands, since they read allKnownRows (rows + groupExtraRows). async loadEntreeGroup(id) { const row = this.allKnownRows.find((r) => r.id === id); if (!row) return; this.groupLoading = true; try { const nids = await fetchGroupeIds(row.nid); const known = new Set(this.allKnownRows.map((r) => r.nid)); const missingNids = nids.filter((nid) => !known.has(nid)); if (missingNids.length) { const { data, includedMap } = await fetchLignesByNids(missingNids); const newRows = buildRows(data, includedMap); const existingIds = new Set(this.groupExtraRows.map((r) => r.id)); this.groupExtraRows = [...this.groupExtraRows, ...newRows.filter((r) => !existingIds.has(r.id))]; } } catch (err) { this.typeUpdateError = err.message; } finally { this.groupLoading = false; } }, startEditType(item) { this.typeUpdateError = null; this.editingTypeId = item.id; }, // Optimistically patches the row in `rows` (not the transient // `item` from groupedRows -- that object is rebuilt from scratch on // every computed re-evaluation, so mutating it wouldn't stick) for // instant feedback, then reconciles with the server in the // background via reloadWindow() (picks up anything else the save // touched, e.g. a cleared field_entree_liee). async saveType(item, event) { const newType = event.target.value; this.editingTypeId = null; if (newType === item.type) return; try { const result = await updateLigneType(item.nid, newType); const row = this.rows.find((r) => r.id === item.id); if (row) { row.type = newType; row.linkable = LINKABLE_TYPES.includes(newType); if (result.entree_liee_cleared) { row.entreeLieeIds = []; row.entreeLieeLabels = []; } } this.reloadWindow(); } catch (err) { this.typeUpdateError = err.message; } }, 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 . setColHover(index) { const table = this.$refs.tableEl; if (!table) return; table.querySelectorAll('.figli-col-hover').forEach((el) => el.classList.remove('figli-col-hover')); this.hoverCol = index; if (index === null) return; table.querySelectorAll('tr').forEach((tr) => { let pos = 0; for (const cell of tr.children) { const span = cell.colSpan || 1; if (index >= pos && index < pos + span) { cell.classList.add('figli-col-hover'); break; } pos += span; } }); }, // showLoading defaults to true for the initial mount, where there's // nothing on screen yet to preserve. A post-edit refresh passes // false: flipping `loading` back to true would unmount the whole // v-else table (the "Chargement…" paragraph takes its place) and // remount it from scratch once the fetch resolves -- exactly the // full-table flicker/scroll-reset Vue's keyed diffing exists to // avoid. Reassigning `rows` in place lets Vue patch just the rows // that actually changed. async load(showLoading = true) { if (showLoading) this.loading = true; this.error = null; try { const today = todayStr(); this.windowStart = addMonths(today, -WINDOW_MONTHS); this.windowEnd = addMonths(today, WINDOW_MONTHS); const { data, includedMap } = await fetchLignes(rangeUrl(this.windowStart, this.windowEnd)); this.rows = buildRows(data, includedMap); } catch (err) { this.error = err.message; } finally { if (showLoading) this.loading = false; } }, // Post-edit refresh: re-fetch exactly the currently loaded window // (not a fresh "last 18 months" window) so editing an old line // doesn't silently reset how far the user had scrolled back. async reloadWindow() { if (!this.windowStart || !this.windowEnd) return; try { const { data, includedMap } = await fetchLignes(rangeUrl(this.windowStart, this.windowEnd)); this.rows = buildRows(data, includedMap); } catch (err) { this.error = err.message; } }, async loadOlder() { // Bypassed while a specific "Année" filter is active -- that mode // loads exactly one year and nothing else (see enterYearMode). // Also bypassed once the window already reaches MIN_LOADABLE_DATE -- // nothing older to fetch, so skip straight out instead of re-firing // an empty request on every subsequent scroll/layout tick. if (this.loadingWindow || !this.windowStart || this.filterYear || this.windowStart <= MIN_LOADABLE_DATE) return; this.loadingWindow = true; this.loadingOlder = true; try { let newStart = addMonths(this.windowStart, -EXTEND_MONTHS); if (newStart < MIN_LOADABLE_DATE) newStart = MIN_LOADABLE_DATE; const { data, includedMap } = await fetchLignes(rangeUrl(newStart, this.windowStart)); const newRows = buildRows(data, includedMap); this.windowStart = newStart; if (newRows.length) { const wrap = this.$refs.tableWrap; const prevScrollHeight = wrap ? wrap.scrollHeight : 0; this.rows = [...newRows, ...this.rows]; await this.$nextTick(); // Prepending pushes existing content down -- keep whatever // the user was looking at in the same visual spot. if (wrap) wrap.scrollTop += wrap.scrollHeight - prevScrollHeight; } // Trim the far (newer) end once the window's grown past the cap // -- happens below the current scroll position, so no visual // jump to compensate for. const maxEnd = addMonths(this.windowStart, this.isFiltering() ? MAX_LOADED_MONTHS_FILTERED : MAX_LOADED_MONTHS); if (this.windowEnd > maxEnd) { this.rows = this.rows.filter((r) => r.date < maxEnd); this.windowEnd = maxEnd; } } catch (err) { this.error = err.message; } finally { this.loadingOlder = false; this.loadingWindow = false; } }, async loadNewer() { // Same MIN_LOADABLE_DATE reasoning as loadOlder(), mirrored at the // future end. if (this.loadingWindow || !this.windowEnd || this.filterYear || this.windowEnd >= MAX_LOADABLE_DATE) return; this.loadingWindow = true; this.loadingNewer = true; try { let newEnd = addMonths(this.windowEnd, EXTEND_MONTHS); if (newEnd > MAX_LOADABLE_DATE) newEnd = MAX_LOADABLE_DATE; const { data, includedMap } = await fetchLignes(rangeUrl(this.windowEnd, newEnd)); const newRows = buildRows(data, includedMap); this.windowEnd = newEnd; if (newRows.length) { // Appended below the viewport -- no scroll compensation needed. this.rows = [...this.rows, ...newRows]; } // Trim the far (older) end once past the cap -- this IS above // the viewport, so compensate scrollTop the same way loadOlder // does for its prepend. const minStart = addMonths(this.windowEnd, this.isFiltering() ? -MAX_LOADED_MONTHS_FILTERED : -MAX_LOADED_MONTHS); if (this.windowStart < minStart) { const wrap = this.$refs.tableWrap; const prevScrollHeight = wrap ? wrap.scrollHeight : 0; const prevScrollTop = wrap ? wrap.scrollTop : 0; this.rows = this.rows.filter((r) => r.date >= minStart); this.windowStart = minStart; await this.$nextTick(); if (wrap) wrap.scrollTop = prevScrollTop - (prevScrollHeight - wrap.scrollHeight); } } catch (err) { this.error = err.message; } finally { this.loadingNewer = false; this.loadingWindow = false; } }, // Scroll-position based rather than IntersectionObserver: one // unified scroll handler drives both edge-loading and // detectCurrentYear() below, instead of maintaining two separate // observer instances for what's fundamentally the same "where is // the user looking right now" question. checkEdges() { const wrap = this.$refs.tableWrap; if (!wrap) return; const threshold = 400; // No overflow at all -- typically a filter (compte/client/type/ // écarts) has thinned the visible rows enough that the loaded // window's worth of matches doesn't fill the viewport. scrollTop // is stuck at 0 either way, so neither of the two checks below // means anything on its own; try both directions instead of // waiting for a scroll that can never happen with nothing to // scroll. const notScrollable = wrap.scrollHeight <= wrap.clientHeight; if (notScrollable || wrap.scrollTop < threshold) this.loadOlder(); if (notScrollable || wrap.scrollTop + wrap.clientHeight > wrap.scrollHeight - threshold) this.loadNewer(); }, // Whether compte/client/type/écarts thin filteredRows down from // whatever's actually loaded -- loadOlder()/loadNewer() use this to // widen the trim cap (MAX_LOADED_MONTHS_FILTERED instead of // MAX_LOADED_MONTHS), since the normal cap actively works against a // sparse filter: extending one end and immediately trimming the // other off nets out to almost the same slice every round, so a // filter with only a few matches a year could search almost forever // without surfacing more of them. isFiltering() { return !!(this.filterCompte.length || this.filterClient || this.filterType.length || this.onlyErrors); }, // Keeps extending the window (both directions) as long as a filter // leaves too few matching rows to fill the viewport -- otherwise // there's never another scroll event to hang further loading off // of, and the sliding window silently stops responding the moment a // filter (compte/client/type/écarts) makes the visible page short // enough to not need a scrollbar. // // Bounded by a round count regardless, as a safety net: addMonths() // uses JS's Date#setMonth(), which isn't invertible for month-end // dates (e.g. 2025-03-31 minus 1 month lands on 2025-03-03, not // 2025-02-28, because setMonth(1) overflows February), so even with // the widened cap above, "no change since last round" isn't a // guaranteed way to detect "there's genuinely nothing more to find". async ensureScrollable(round = 0) { if (this.filterYear || round >= MAX_ENSURE_SCROLLABLE_ROUNDS) return; const wrap = this.$refs.tableWrap; if (!wrap) return; await this.$nextTick(); if (wrap.scrollHeight > wrap.clientHeight) return; const beforeStart = this.windowStart; const beforeEnd = this.windowEnd; await this.loadOlder(); await this.loadNewer(); if (this.windowStart === beforeStart && this.windowEnd === beforeEnd) return; await this.ensureScrollable(round + 1); }, // Which year is "at the top" of the visible area right now (just // under the sticky thead) -- drives the totals footer. Works // regardless of grouping mode: every rendered row (group header or // data row) carries a data-year attribute. detectCurrentYear() { const wrap = this.$refs.tableWrap; const table = this.$refs.tableEl; if (!wrap || !table) return; const thead = table.querySelector('thead'); const wrapRect = wrap.getBoundingClientRect(); const thresholdY = wrapRect.top + (thead ? thead.getBoundingClientRect().height : 0) + 2; const rows = table.querySelectorAll('tbody tr[data-year]'); let year = null; for (const tr of rows) { const rect = tr.getBoundingClientRect(); if (rect.bottom > thresholdY) { year = tr.dataset.year; break; } } if (year && year !== this.currentYear) { this.currentYear = year; this.loadCurrentYearTotals(); } }, // No requestAnimationFrame throttle here anymore -- it used a // "skip if a frame is already pending" guard reset from *inside* // the rAF callback, which meant a single rAF callback that never // fires (observed: a backgrounded/non-visible tab, where browsers // routinely throttle or fully suspend rAF) permanently wedges the // guard true, silently dropping every future scroll event forever // -- exactly the "loads once then stops, can't scroll back" bug // this replaces. detectCurrentYear()/checkEdges() are cheap (DOM // reads + early-return guards, no work of their own), so calling // them on every scroll event costs nothing worth throttling for. onScroll() { this.detectCurrentYear(); this.checkEdges(); }, // mounted() only runs once, but `v-if="loading"` swaps out the //
for a fresh DOM node every time loading // toggles true -> false (enterYearMode/exitYearMode/jumpToYear all // do this) -- the old node's scroll listener goes with it, silently // leaving the new one with no listener at all (checkEdges() and // detectCurrentYear(), and so the footer, stop responding to // scroll). Call this after every such transition, once the new // wrap exists; the dataset flag makes it a no-op if the element is // unchanged. ensureScrollListener() { const wrap = this.$refs.tableWrap; if (wrap && !wrap.dataset.figliScrollBound) { wrap.dataset.figliScrollBound = '1'; wrap.addEventListener('scroll', this.onScroll, { passive: true }); } }, async loadCurrentYearTotals() { if (!this.currentYear) return; this.currentYearLoading = true; try { this.currentYearTotals = await fetchYearTotals(this.currentYear); } catch (err) { this.currentYearTotals = null; } finally { this.currentYearLoading = false; } }, // Picking a specific "Année" bypasses the sliding window entirely -- // load exactly that year (a bounded, small fetch on its own) rather // than post-filtering whatever happens to be in the current window, // which could easily be empty for a year the user hasn't scrolled // to yet. async enterYearMode(year) { this.loading = true; this.error = null; try { const start = year + '-01-01'; const end = (parseInt(year, 10) + 1) + '-01-01'; const { data, includedMap } = await fetchLignes(rangeUrl(start, end)); this.rows = buildRows(data, includedMap); this.windowStart = start; this.windowEnd = end; } catch (err) { this.error = err.message; } finally { this.loading = false; } await this.$nextTick(); if (this.$refs.tableWrap) this.$refs.tableWrap.scrollTop = 0; this.ensureScrollListener(); this.detectCurrentYear(); }, // Back to "Toutes" -- resume the normal sliding window, re-centered // on today rather than trying to resume exactly where the year // filter was applied from. async exitYearMode() { await this.load(); await this.$nextTick(); const wrap = this.$refs.tableWrap; if (wrap) wrap.scrollTop = wrap.scrollHeight; this.ensureScrollListener(); this.detectCurrentYear(); }, // "Aller à" -- a one-shot navigation shortcut, distinct from the // "Année" filter: it re-centers the sliding window on 1 January of // the chosen year and scrolls there, but (unlike filterYear) doesn't // restrict the view to just that year -- scrolling up/down still // extends the window normally, so you can still see the overlap // with neighbouring years. onJumpYearChange() { const year = this.jumpYearValue; this.jumpYearValue = ''; // reset immediately -- this is an action, not a sticky filter this.jumpToYear(year); }, async jumpToYear(year) { if (!year) return; // A year filter would otherwise fight with the window this sets // below -- clear it first, silencing the watcher's own // exitYearMode() so its (different) window doesn't race this one. if (this.filterYear) { this._skipYearWatch = true; this.filterYear = ''; } this.lastJumpYear = year; this.syncHash(); this.loading = true; this.error = null; try { const yearStart = year + '-01-01'; this.windowStart = yearStart; this.windowEnd = addMonths(yearStart, WINDOW_MONTHS * 2); const { data, includedMap } = await fetchLignes(rangeUrl(this.windowStart, this.windowEnd)); this.rows = buildRows(data, includedMap); } catch (err) { this.error = err.message; } finally { this.loading = false; } await this.$nextTick(); const wrap = this.$refs.tableWrap; if (wrap) wrap.scrollTop = 0; this.ensureScrollListener(); this.detectCurrentYear(); this.checkEdges(); }, // Reflects the current filters + "Aller à" target in the URL hash // (replacing, not pushing, so tweaking a dropdown doesn't spam the // back button with history entries) -- reload the page or share the // link and readHashState()/mounted() reproduce the same view. syncHash() { const hash = buildHashString({ filterCompte: this.filterCompte, filterClient: this.filterClient, filterType: this.filterType, filterYear: this.filterYear, onlyErrors: this.onlyErrors, lastJumpYear: this.lastJumpYear, }); const url = location.pathname + location.search + (hash ? '#' + hash : ''); history.replaceState(null, '', url); }, }, watch: { filterYear(newYear, oldYear) { if (this._skipYearWatch) { this._skipYearWatch = false; return; } if (newYear) { this.enterYearMode(newYear); } else if (oldYear) { this.exitYearMode(); } this.syncHash(); }, // Any of these can thin filteredRows enough to remove the // scrollbar the sliding window relies on to keep loading -- see // ensureScrollable(). filterYear is handled separately above (it // swaps the whole loading strategy, not just the visible subset). // Each also reflects into the URL hash so the filtered view is // reloadable/shareable -- see syncHash(). filterCompte() { this.ensureScrollable(); this.syncHash(); }, filterClient() { this.ensureScrollable(); this.syncHash(); }, filterType() { this.ensureScrollable(); this.syncHash(); }, onlyErrors() { this.ensureScrollable(); this.syncHash(); }, }, async mounted() { // Reproduce whatever the URL hash describes -- reload or a shared // link should land on the same filtered view. The four basic // filters just narrow filteredRows, so they're safe to set before // deciding how to load; filterYear/aller each own their loading // path (enterYearMode()/jumpToYear()), so at most one of those // runs instead of the default load(). const hashState = readHashState(); this.filterCompte = hashState.filterCompte; this.filterClient = hashState.filterClient; this.filterType = hashState.filterType; this.onlyErrors = hashState.onlyErrors; if (hashState.filterYear) { this.filterYear = hashState.filterYear; } else if (hashState.aller) { this.lastJumpYear = hashState.aller; this.jumpToYear(hashState.aller); } else { await this.load(); await this.$nextTick(); const wrap = this.$refs.tableWrap; this.ensureScrollListener(); if (wrap) { // Start scrolled to the most recent data (the window is // centered on "today", so that's the bottom of what's loaded). wrap.scrollTop = wrap.scrollHeight; } this.detectCurrentYear(); this.checkEdges(); this.ensureScrollable(); } // Independent of the row window, so the dropdowns don't shrink to // "whatever happens to be loaded right now". fetchClientNames().then((names) => { this.allClientsList = names; }).catch(() => {}); fetchYearsList().then((years) => { this.allYearsList = years; }).catch(() => {}); fetchOuvertureEcarts().then((ecarts) => { this.ouvertureEcarts = ecarts; }).catch(() => {}); jQuery(document).on('dialog:afterclose', () => this.reloadWindow()); }, }; Drupal.behaviors.figliComptaHome = { attach(context) { const root = context.querySelector ? context.querySelector('#figli-home-app') : null; if (root && !root.dataset.figliInitialized) { root.dataset.figliInitialized = '1'; Vue.createApp(App).mount(root); } }, }; })(Drupal, Vue, jQuery);