/** * @file * Progressive decoupling: Drupal renders the page shell (nav, auth via * session cookie, the "Ajouter une ligne" modal form); this Vue app fetches * 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, 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. * * Filtering: every toolbar filter (compte, client, type, signalement, * écarts, recherche libre) is applied server-side, folded straight into * whatever request populates the window -- see LedgerRowsController and * fetchFilteredLignes() below. `rows` therefore only ever holds rows that * both fall in the loaded date range AND match the active filters; there's * no separate "load everything, filter client-side" pass. JSON:API is * still used for a few things the filtered endpoint doesn't cover: the * entrée/sortie group drill-down (fetchLignesByNids()) and live-update * polling (fetchChangedSince()), both keyed on specific node ids or * "anything that changed" rather than a date range + filter set. */ (function (Drupal, Vue, jQuery) { 'use strict'; const API_BASE = '/jsonapi/node/ligne_comptable'; const EUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }); // Not style: 'percent' -- that expects a fraction (0.10) and field_tva // stores a plain rate (10), which style: 'percent' would otherwise // silently read as 1000%. const PCT = new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); // Mirrors the $taux_officiels select in figli_compta_ledger_form_alter() // and the $official list in figli_compta_ledger_update_8007()/_8008() -- // same 0.05-point tolerance as those migration scripts, for the same // "rounding through 2-decimal HT/TTC storage" reason. const OFFICIAL_TVA_RATES = [0, 2.1, 5.5, 10, 20]; const OFFICIAL_TVA_EPSILON = 0.05; 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). 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; // How often to poll for other users' changes (see pollForChanges()) -- // a plain setInterval rather than anything push-based (no Socket.io/ // websocket infra for a 6-person internal tool); this is far more // frequent than table content actually changes, but the requests are // cheap and it keeps everyone's view close to live. const POLL_INTERVAL_MS = 8000; // 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 query string for LedgerRowsController::index() (GET // /lignes/api/lignes) -- the server-side-filtered replacement for // "fetch a JSON:API date range, then filter client-side": every // toolbar filter is folded straight into the same request that loads // the window, so `rows` only ever holds what's actually meant to be // visible right now, pre-shaped exactly like buildRows() below would // have produced from JSON:API (no separate row-building step needed // for this path). rangeOrAnnee is either {start, end} (the normal // sliding window, and jumpToYear()) or {annee} (filterYear's // enterYearMode) -- the controller accepts either. function filteredLignesUrl(rangeOrAnnee, filters) { const params = new URLSearchParams(); if (rangeOrAnnee.annee) { params.set('annee', rangeOrAnnee.annee); } else { params.set('start', rangeOrAnnee.start); params.set('end', rangeOrAnnee.end); } if (filters.compte.length) params.set('compte', filters.compte.join(',')); if (filters.client) params.set('client', filters.client); if (filters.type.length) params.set('type', filters.type.join(',')); if (filters.flag.length) params.set('flag', filters.flag.join(',')); if (filters.q) params.set('q', filters.q); if (filters.ecarts) params.set('ecarts', '1'); if (filters.signale) params.set('signale', '1'); return '/lignes/api/lignes?' + params.toString(); } async function fetchFilteredLignes(rangeOrAnnee, filters) { const res = await fetch(filteredLignesUrl(rangeOrAnnee, filters), { headers: { Accept: 'application/json' } }); const json = await res.json().catch(() => ({})); if (!res.ok) throw new Error(json.error || ('/lignes/api/lignes a répondu ' + res.status)); return json.rows; } 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,field_flag'); 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()); } // Any ligne_comptable changed after `tsSeconds` (a Unix timestamp) -- // backs the polling in pollForChanges() below, so one user's edit // shows up in everyone else's table without a manual reload. `changed` // is stored as a Unix timestamp internally; JSON:API exposes it as an // ISO 8601 string in *responses* but only accepts the raw timestamp as // a *filter value* -- confirmed live (an ISO string filter value // silently matched everything instead of narrowing the query). async function fetchChangedSince(tsSeconds) { const params = new URLSearchParams(); params.set('include', 'field_repartition,field_repartition.field_compte,field_client,field_entree_liee,field_flag'); params.set('filter[changedFilter][condition][path]', 'changed'); params.set('filter[changedFilter][condition][operator]', '>'); params.set('filter[changedFilter][condition][value]', String(tsSeconds)); params.set('sort', 'changed'); 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(); } // Same pagination reasoning as fetchClientNames() above -- feeds the // "Signalement" column's datalist (suggestions only, doesn't restrict // input: see LedgerActionsController::updateField()'s 'flag' case). async function fetchFlagNames() { let url = '/jsonapi/taxonomy_term/flag?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. // `changed` (the row's last-known changed timestamp) lets the server // reject a save-over-a-stale-view instead of silently overwriting // someone else's concurrent edit -- see LedgerActionsController:: // checkConflict(). The thrown error's `.status` lets callers tell a // 409 conflict apart from any other failure. async function updateLigneType(nid, type, changed) { 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, changed }), }); const json = await res.json().catch(() => ({})); if (!res.ok) { const err = new Error(json.error || ('/lignes/' + nid + '/type a répondu ' + res.status)); err.status = res.status; throw err; } return json; } // POST /lignes/{nid}/champ -- change client/facture/libellé without // opening the full edit form. Same fresh-token-per-call and conflict- // detection reasoning as updateLigneType() above. async function updateLigneField(nid, field, value, changed) { const tokenRes = await fetch('/session/token'); const token = await tokenRes.text(); const res = await fetch('/lignes/' + nid + '/champ', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token }, body: JSON.stringify({ field, value, changed }), }); const json = await res.json().catch(() => ({})); if (!res.ok) { const err = new Error(json.error || ('/lignes/' + nid + '/champ a répondu ' + res.status)); err.status = res.status; throw err; } return json; } // Sums the same fields LedgerRowsController::index() rows carry into // the {montant_ht, cotisation, montant_ttc, ecart, par_compte} shape // the footer template expects. function aggregateTotals(rows) { let montantHt = 0; let cotisation = 0; let montantTtc = 0; let ecart = 0; const parCompte = {}; for (const r of rows) { montantHt += r.montant_ht || 0; cotisation += r.cotisation || 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), cotisation: round(cotisation), montant_ttc: round(montantTtc), ecart: round(ecart), par_compte: Object.fromEntries(Object.entries(parCompte).map(([c, v]) => [c, round(v)])), }; } // Per-année footer totals, filtered the same way the table itself is -- // fetches every row for the year via the same server-side-filtered // endpoint the table window uses (fetchFilteredLignes), then sums them // client-side. Replaces the old /lignes/api/totaux (LedgerStatsController:: // totauxAnnee(), always unfiltered) now that the footer needs to // reflect whatever's actually on screen, not the whole year regardless // of the active filters. async function fetchYearTotals(annee, filters) { const rows = await fetchFilteredLignes({ annee }, filters); return Object.assign({ annee }, aggregateTotals(rows)); } // 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, // Which row+field (client/facture/libelle) is currently showing // its inline instead of the plain text -- only one at a // time, mirroring editingTypeId above. { id, field } or null. editingCell: null, // Row currently open in the signalement modal (see // openFlagModal()) -- a small standalone object, not the row // itself, and the text of a not-yet-added tag typed into that // modal's own input. flagModalItem: null, flagModalNewTag: '', // Fenêtre glissante. windowStart: null, windowEnd: null, // Drive the sentinel-row "Chargement…" text only -- actual // serialization is the `_windowOpChain` queue in // _queueWindowOp(), not these (see loadOlder()/loadNewer()). loadingOlder: false, loadingNewer: 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)); }, // 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)])), }; }, // `rows` no longer needs a separate client-side filtering pass -- // every toolbar filter is already applied server-side by whatever // fetched the current window (see fetchFilteredLignes()), so this // only handles the month/year grouping on top. groupedRows() { const list = this.rows; 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); }, // Historical lines can carry a backfilled, non-round rate (e.g. // 12.6414 -- see figli_compta_ledger_update_8005()'s docblock for // why); rounded to 2 decimals for display, same as every amount // column here, rather than showing the full stored precision. formatPct(v) { return v === null || v === undefined ? '' : PCT.format(v) + ' %'; }, // Flags a TVA rate that isn't one of the 5 official French VAT // rates -- either a genuinely blended/multi-rate invoice collapsed // into one ligne_comptable, or a migrated line // figli_compta_ledger_update_8007() left ambiguous rather than // guess (see that function's docblock). Null (no TVA recorded at // all) is never flagged -- nothing to question there. isTvaOfficial(v) { if (v === null || v === undefined) return true; return OFFICIAL_TVA_RATES.some((o) => Math.abs(v - o) < OFFICIAL_TVA_EPSILON); }, // É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) { const montant = Math.round(resteAVerser * 100) / 100; return { kind: 'reste', montant, detail: 'Reste à verser (comptes de cette ligne) : ' + this.formatEur(montant) }; } if (surVerse > 0.01) { const montant = Math.round(surVerse * 100) / 100; return { kind: 'sur-verse', montant, detail: 'Sur-versé (comptes de cette ligne) : ' + this.formatEur(montant) }; } 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', montant: 0, detail: 'Lié à ' + n + ' entrée' + (n > 1 ? 's' : '') + ' client' + (n > 1 ? 's' : '') + '.' }; }, // Takes the whole status object (not just kind) -- reste/sur-verse/ // ok show the exact solde right in the badge, not just in the // hover title, since a bare "Reste à verser" with no figure meant // opening the drill-down modal just to see how much. linkStatusLabel(status) { if (status.kind === 'non-liee') return 'Non liée'; if (status.kind === 'reste') return 'Reste à verser : ' + this.formatEur(status.montant); if (status.kind === 'sur-verse') return 'Sur-versé : ' + this.formatEur(status.montant); if (status.kind === 'inconnu') return 'À vérifier'; return 'Lié : ' + this.formatEur(status.montant); }, // 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', }; }, // aa/mm/jj -- shorter than the API's ISO yyyy-mm-dd (saves column // width in a table already packed with 8 compte columns) while // keeping the same year-month-day ordering, so it still sorts // correctly as plain text and reads unambiguously either way. formatDate(iso) { if (!iso) return ''; const [y, m, d] = iso.split('-'); return y.slice(2) + '/' + m + '/' + d; }, 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. fallbackNid: see loadEntreeGroup() below -- // clicking a versement's badge passes the versement's own nid, // since the entrée id alone isn't enough to start the server-side // lookup when that entrée isn't loaded at all. toggleEntreeFilter(id, fallbackNid) { if (this.filterEntreeId === id) { this.closeDrilldown(); return; } this.filterEntreeId = id; this.loadEntreeGroup(id, fallbackNid); }, closeDrilldown() { this.filterEntreeId = null; }, // Low-level: fetches /lignes/api/groupe/{nid}'s full member list // (the sliding window can't discover it client-side -- see // LedgerStatsController::groupeEntree()) and merges whatever isn't // already known locally into groupExtraRows. filterEntreeGroup/ // reconciliationByEntree re-evaluate automatically once that // lands, since they read allKnownRows (rows + groupExtraRows). // Shared by loadEntreeGroup() (click-triggered) and // ensureLinkedReconciliationResolved() (background) below. async fetchGroupFromNid(nid) { const nids = await fetchGroupeIds(nid); const known = new Set(this.allKnownRows.map((r) => r.nid)); const missingNids = nids.filter((n) => !known.has(n)); 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))]; } }, // Resolves the full transitive-closure group for the given row id. // id itself might not be loaded at all -- a versement's linked // entrée can be dated years before or after it -- so fallbackNid // lets the caller supply a *different*, guaranteed-loaded node's // nid (e.g. the versement's own) to start the server-side // traversal from instead; groupeEntree() finds the same connected // component either way. async loadEntreeGroup(id, fallbackNid) { const row = this.allKnownRows.find((r) => r.id === id); const startNid = row ? row.nid : fallbackNid; if (!startNid) return; this.groupLoading = true; try { await this.fetchGroupFromNid(startNid); } catch (err) { this.typeUpdateError = err.message; } finally { this.groupLoading = false; } }, // Proactively resolves the same group for every visible linkable // row instead of waiting for a click -- a linked entrée can be // dated years before or after the sortie that pays it out, so the // reste à verser/sur-versé badge can never be answered correctly // from whatever the sliding window happens to have loaded. // Triggered by the `rows` watcher, so it re-runs after every // load/loadOlder/loadNewer. Marks every entrée id a row links to // (not just the first) before fetching, so another row sharing // one of the same entrées doesn't queue a redundant fetch for it. // Errors are swallowed here (unlike loadEntreeGroup() above) -- // this is unattended background work, not something the user // explicitly asked for, so a failure just leaves the badge // "inconnu" for that row instead of surfacing an error banner. async ensureLinkedReconciliationResolved() { const toFetch = []; for (const r of this.rows) { if (!LINKABLE_TYPES.includes(r.type) || !r.entreeLieeIds.length) continue; if (r.entreeLieeIds.every((eid) => this.resolvedLinkGroups.has(eid))) continue; r.entreeLieeIds.forEach((eid) => this.resolvedLinkGroups.add(eid)); toFetch.push(r.nid); } await Promise.all(toFetch.map((nid) => this.fetchGroupFromNid(nid).catch(() => {}))); }, // Live multi-user updates without websocket/Socket.io infra -- // plain polling (see POLL_INTERVAL_MS) is simple, fits the // existing fetch-based architecture, and is plenty for a // 6-person internal tool. A failed poll is silent and just tried // again next tick (matches other background-refresh code here); // lastPollTs is deliberately *not* advanced on failure, so a // transient network blip doesn't miss whatever changed during it. startPolling() { setInterval(() => this.pollForChanges(), POLL_INTERVAL_MS); }, async pollForChanges() { // setInterval doesn't wait for a previous callback's promise to // settle before scheduling the next tick -- without this guard, // a poll that takes longer than POLL_INTERVAL_MS to respond // (slow network, tab was backgrounded and just resumed) would // overlap with the next one, both merging into `rows` at once. if (this._pollInFlight) return; this._pollInFlight = true; try { const { data, includedMap } = await fetchChangedSince(this.lastPollTs); if (data.length) { this.mergeChangedRows(buildRows(data, includedMap)); } this.lastPollTs = Math.floor(Date.now() / 1000); } catch (err) { // silent -- see comment above. } finally { this._pollInFlight = false; } }, // Patches already-loaded rows in place (mutates each row's own // properties via Object.assign, not the `rows`/`groupExtraRows` // arrays themselves) so Vue's reactivity updates just the // affected cells, without re-triggering the `rows` watcher (and // so ensureLinkedReconciliationResolved()) for routine polls that // only touch content already known locally. A row currently being // edited is left alone entirely -- overwriting its value out from // under an in-progress keystroke would be worse than a few // seconds of staleness; it'll pick up on the next poll once // editing finishes. Genuinely new lines (not loaded at all yet) // that fall inside the current window get appended -- that's the // one case that *does* need the array reassigned, since there's // no existing row object to mutate. // // `rows` is server-filtered (see fetchFilteredLignes()), unlike // before Phase 2 where it held the whole window and a computed // property filtered it for display -- pollForChanges() is // deliberately still unfiltered/global (see its own comment), so a // change reported here might have moved a row *out* of the active // filters (patch would leave a stale non-matching row visible) or // *into* them (a row not previously loaded now qualifies). A row // living in groupExtraRows instead is unaffected by the toolbar // filters at all (that's the drill-down modal's own concern), so // it's always just patched in place. mergeChangedRows(changedRows) { const isBeingEdited = (id) => this.editingTypeId === id || (this.editingCell && this.editingCell.id === id); const rowsById = new Map(this.rows.map((r) => [r.id, r])); const extraById = new Map(this.groupExtraRows.map((r) => [r.id, r])); const newOnes = []; const removeIds = new Set(); for (const fresh of changedRows) { if (isBeingEdited(fresh.id)) continue; const inExtra = extraById.get(fresh.id); if (inExtra) { Object.assign(inExtra, fresh); continue; } const inWindow = fresh.date >= this.windowStart && fresh.date < this.windowEnd; const matches = inWindow && this.rowMatchesFilters(fresh); const existing = rowsById.get(fresh.id); if (existing) { if (matches) { Object.assign(existing, fresh); } else { removeIds.add(fresh.id); } } else if (matches) { newOnes.push(fresh); } } if (newOnes.length || removeIds.size) { this.rows = [...this.rows.filter((r) => !removeIds.has(r.id)), ...newOnes].sort((a, b) => (a.date || '').localeCompare(b.date || '')); } }, 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, item.changed); const row = this.rows.find((r) => r.id === item.id); if (row) { row.type = newType; row.linkable = LINKABLE_TYPES.includes(newType); row.changed = result.changed; if (result.entree_liee_cleared) { row.entreeLieeIds = []; row.entreeLieeLabels = []; } } this.reloadWindow(); } catch (err) { this.typeUpdateError = err.message; if (err.status === 409) this.refreshSingleRow(item.id, item.nid); } }, startEditCell(item, field) { this.typeUpdateError = null; this.editingCell = { id: item.id, field }; }, isEditingCell(item, field) { return !!this.editingCell && this.editingCell.id === item.id && this.editingCell.field === field; }, // Same optimistic-patch-then-close pattern as saveType() above -- // client/facture/libellé don't affect linkability or // field_entree_liee, so there's nothing else to reconcile via // reloadWindow() here. Signalement (field_flag) isn't handled here // -- it's multi-value and edited through its own modal, see // openFlagModal()/saveFlagList() below. async saveCell(item, field, event) { const newValue = event.target.value.trim(); this.editingCell = null; if (newValue === (item[field] || '')) return; try { const result = await updateLigneField(item.nid, field, newValue, item.changed); const row = this.rows.find((r) => r.id === item.id); if (row) { row[field] = result.value; row.changed = result.changed; } // A client name with no existing match gets created on the fly // (server-side) rather than rejected -- reflect it in the // filter dropdown/datalist immediately instead of only after a // reload picks up the new taxonomy term via fetchClientNames(). if (field === 'client' && result.value && !this.allClientsList.includes(result.value)) { this.allClientsList = [...this.allClientsList, result.value].sort(); } } catch (err) { this.typeUpdateError = err.message; if (err.status === 409) this.refreshSingleRow(item.id, item.nid); } }, // Signalement modal: one tag per line (add/remove), not a single // comma-separated text field -- easier to see what's already there // and remove just one without retyping the rest. flagModalItem is // its own small reactive object (not aliased to the row in `rows`) // so the modal keeps working even if the underlying row gets // trimmed out of the sliding window while it's open; saveFlagList() // updates both explicitly. openFlagModal(item) { this.typeUpdateError = null; this.flagModalItem = { id: item.id, nid: item.nid, date: item.date, client: item.client, changed: item.changed, flags: item.flags.slice() }; this.flagModalNewTag = ''; }, closeFlagModal() { this.flagModalItem = null; this.flagModalNewTag = ''; }, async addFlagTag() { const name = this.flagModalNewTag.trim(); if (!name || this.flagModalItem.flags.includes(name)) { this.flagModalNewTag = ''; return; } this.flagModalNewTag = ''; await this.saveFlagList([...this.flagModalItem.flags, name]); }, async removeFlagTag(name) { await this.saveFlagList(this.flagModalItem.flags.filter((f) => f !== name)); }, async saveFlagList(newFlags) { const item = this.flagModalItem; try { const result = await updateLigneField(item.nid, 'flag', newFlags.join(', '), item.changed); item.flags = result.value; item.changed = result.changed; const row = this.rows.find((r) => r.id === item.id) || this.groupExtraRows.find((r) => r.id === item.id); if (row) { row.flags = result.value; row.hasFlag = result.value.length > 0; row.changed = result.changed; } const newNames = result.value.filter((name) => !this.allFlagsList.includes(name)); if (newNames.length) this.allFlagsList = [...this.allFlagsList, ...newNames].sort(); } catch (err) { this.typeUpdateError = err.message; if (err.status === 409) { this.refreshSingleRow(item.id, item.nid); this.closeFlagModal(); } } }, // 409 conflict from saveType()/saveCell() above -- someone else // saved this exact line in between. Re-fetch just this one node // and patch it in place, so the row reflects their change right // away instead of staying stuck on the stale view that caused the // rejection (the user would otherwise hit the same conflict again // on retry without understanding why). async refreshSingleRow(id, nid) { try { const { data, includedMap } = await fetchLignesByNids([nid]); if (!data.length) return; const [freshRow] = buildRows(data, includedMap); const existing = this.rows.find((r) => r.id === id) || this.groupExtraRows.find((r) => r.id === id); if (existing) Object.assign(existing, freshRow); } catch (err) { // Best effort -- the error banner from the conflict itself // already told the user to reload if this doesn't work out. } }, 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); this.rows = await fetchFilteredLignes({ start: this.windowStart, end: this.windowEnd }, this.currentFilters()); } 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. Also // the reload path every toolbar filter change routes through (see // onFilterChanged()) -- with filtering now server-side, changing a // filter has to re-fetch, not just re-scan what's already loaded. async reloadWindow() { if (!this.windowStart || !this.windowEnd) return; try { this.rows = await fetchFilteredLignes({ start: this.windowStart, end: this.windowEnd }, this.currentFilters()); } catch (err) { this.error = err.message; } }, // Public entry points queue onto a shared chain (see _queueWindowOp) // instead of running immediately -- see that method for why a // "drop if busy" guard used to sit here and why it had to go. loadOlder() { return this._queueWindowOp(() => this._loadOlderNow()); }, loadNewer() { return this._queueWindowOp(() => this._loadNewerNow()); }, // Both loadOlder()/loadNewer() 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 -- // interleaving them races on that shared state (observed earlier // this session as `rows` transiently emptying out). That used to be // guarded by a `loadingWindow` boolean that made a call arriving // while another was in flight a silent no-op -- but a no-op is // indistinguishable from "there's nothing more to load", which is // exactly the signal ensureScrollable() uses to stop recursing. In // practice that meant several filters restored from the URL hash at // once (mounted() sets filterCompte/filterClient/filterType as // three separate reactive writes, each firing its own watcher) could // each kick off loadOlder()/loadNewer(), have all but one silently // dropped by the guard, and have ensureScrollable() conclude "window // didn't change" and give up after a single round -- even though the // table was nowhere near scrollable yet, and with nothing left to // ever retry (no scrollbar, no more scroll events). Queuing instead // of dropping means every call still eventually runs, in order, once // whatever's ahead of it in the queue finishes. _queueWindowOp(fn) { const run = () => fn().catch((err) => { this.error = err.message; }); this._windowOpChain = (this._windowOpChain || Promise.resolve()).then(run, run); return this._windowOpChain; }, async _loadOlderNow() { // 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. Checked // here (at actual run time, not call time) since by the time this // reaches the front of the queue, windowStart may have moved. if (!this.windowStart || this.filterYear || this.windowStart <= MIN_LOADABLE_DATE) return; this.loadingOlder = true; try { let newStart = addMonths(this.windowStart, -EXTEND_MONTHS); if (newStart < MIN_LOADABLE_DATE) newStart = MIN_LOADABLE_DATE; const newRows = await fetchFilteredLignes({ start: newStart, end: this.windowStart }, this.currentFilters()); 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. This // write fires its own native 'scroll' event, re-entering // checkEdges() -- harmless: loadingOlder is still true at // this point (reset only in the `finally` below), so that // event finds the guard there already blocking a redundant // call, no separate suppression needed. 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; } }, async _loadNewerNow() { // Same MIN_LOADABLE_DATE reasoning as _loadOlderNow(), mirrored at // the future end. if (!this.windowEnd || this.filterYear || this.windowEnd >= MAX_LOADABLE_DATE) return; this.loadingNewer = true; try { let newEnd = addMonths(this.windowEnd, EXTEND_MONTHS); if (newEnd > MAX_LOADABLE_DATE) newEnd = MAX_LOADABLE_DATE; const newRows = await fetchFilteredLignes({ start: this.windowEnd, end: newEnd }, this.currentFilters()); 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; } }, // 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; // Guarded by loadingOlder/loadingNewer, not just called // unconditionally: loadOlder()/loadNewer() queue instead of // dropping a call that arrives while one's already running (see // _queueWindowOp) -- correct for the *distinct* triggers that fix // was about (several filters changing in the same tick), but a // single continuous scroll gesture fires many native 'scroll' // events, and checkEdges() runs on every one of them. Without // this guard, every one of those events near an edge queued its // own loadOlder()/loadNewer() call, and every queued call did a // real fetch + scroll compensation regardless of whether the // very first one already moved the window away from the edge -- // that pileup is what looked like the same request firing over // and over, dragging the scroll position around unpredictably. if ((notScrollable || wrap.scrollTop < threshold) && !this.loadingOlder) this.loadOlder(); if ((notScrollable || wrap.scrollTop + wrap.clientHeight > wrap.scrollHeight - threshold) && !this.loadingNewer) this.loadNewer(); }, // Whether an active filter (server-side now, see // fetchFilteredLignes()) thins `rows` down to fewer matches than an // unfiltered window would have -- 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.filterFlag.length || this.filterQ || this.onlyErrors || this.onlyFlagged); }, // The non-date part of every toolbar filter, in the shape // fetchFilteredLignes()/filteredLignesUrl() expect -- built fresh // from current state each call rather than kept as its own piece // of reactive state, so there's only one source of truth for what // "the active filters" are. currentFilters() { return { compte: this.filterCompte, client: this.filterClient, type: this.filterType, flag: this.filterFlag, q: this.filterQ, ecarts: this.onlyErrors, signale: this.onlyFlagged, }; }, // Client-side mirror of the non-date conditions LedgerRowsController // applies server-side -- used only by mergeChangedRows() below, to // decide whether a row a poll just reported on still belongs in // `rows` (which, unlike before Phase 2, only ever holds matches for // the active filters, not the whole window). filterYear/the date // window itself is checked separately by the caller, since // windowStart/windowEnd already cover both the sliding-window and // year-mode cases identically. rowMatchesFilters(row) { if (this.filterCompte.length && !this.filterCompte.some((c) => row.parCompte[c] !== undefined)) return false; if (this.filterClient && row.client !== this.filterClient) return false; if (this.filterType.length && !this.filterType.includes(row.type)) return false; if (this.filterFlag.length && !this.filterFlag.some((f) => row.flags.includes(f))) return false; if (this.filterQ && !(row.libelle || '').toLowerCase().includes(this.filterQ.toLowerCase())) return false; if (this.onlyErrors && !row.hasError) return false; if (this.onlyFlagged && !row.hasFlag) return false; return true; }, // Every toolbar filter change routes through here: with filtering // now server-side (see fetchFilteredLignes()), there's no more // "just recompute a client-side view" -- the currently loaded // window has to be re-fetched with the new filter applied. The // reload itself is queued through the same chain as loadOlder()/ // loadNewer() (see _queueWindowOp) since several filters can change // in the same tick (e.g. mounted() restoring them all from the URL // hash at once), and interleaving their fetches would race on // `rows` the same way parallel loadOlder()/loadNewer() calls used // to. ensureScrollable() is deliberately chained AFTER that queued // op settles, not passed into it: ensureScrollable() itself calls // loadOlder()/loadNewer(), which each enqueue their own op onto the // very same chain -- queuing it *inside* the op currently occupying // that chain made the chain await its own continuation (the queued // op can't finish until its child call, appended behind it on the // same chain, finishes first) and deadlocked solid the moment a // filter actually left too few rows to fill the viewport, wedging // every future filter change and scroll-triggered load right along // with it. // // loadCurrentYearTotals() is called unconditionally here (not just // left to detectCurrentYear()'s own trigger): that one only // re-fetches when the *visible year* changes, so a filter change // while staying on the same year would otherwise leave the footer // showing stale, pre-filter totals. Independent of the window // reload above (different endpoint call, no shared state), so no // need to chain it through the same queue. onFilterChanged() { this._queueWindowOp(() => this.reloadWindow()).then(() => this.ensureScrollable()); this.loadCurrentYearTotals(); this.syncHash(); }, // 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". // // Public entry point is serialized: mounted() restoring several // filters from the URL hash (filterCompte/filterClient/filterType) // assigns each as its own reactive property write, so each fires // its own watcher and its own independent ensureScrollable(0) call // in the very same tick. Without serializing, those chains race on // loadOlder()/loadNewer()'s shared `loadingWindow` guard -- a call // that lands while another is already in flight just no-ops // immediately, so a chain can see "window didn't change" and give // up after one round even though the table is nowhere near // scrollable yet, and nothing is left afterward to ever retry // (there's no scrollbar to generate the scroll events checkEdges() // would otherwise rely on). Queuing instead of racing means the // first call runs its full recursive loop uninterrupted, and any // calls that arrived while it was busy collapse into a single // follow-up run once it's done (covering the final filter state, // cheap/no-op if that first run already converged). ensureScrollable() { if (this._ensureScrollableRunning) { this._ensureScrollablePending = true; return this._ensureScrollableRunning; } this._ensureScrollableRunning = this._ensureScrollableLoop(0).finally(() => { this._ensureScrollableRunning = null; if (this._ensureScrollablePending) { this._ensureScrollablePending = false; this.ensureScrollable(); } }); return this._ensureScrollableRunning; }, async _ensureScrollableLoop(round) { 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._ensureScrollableLoop(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, this.currentFilters()); } 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'; this.rows = await fetchFilteredLignes({ annee: year }, this.currentFilters()); 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); this.rows = await fetchFilteredLignes({ start: this.windowStart, end: this.windowEnd }, this.currentFilters()); } 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, filterFlag: this.filterFlag, filterYear: this.filterYear, filterQ: this.filterQ, onlyErrors: this.onlyErrors, onlyFlagged: this.onlyFlagged, lastJumpYear: this.lastJumpYear, }); const url = location.pathname + location.search + (hash ? '#' + hash : ''); history.replaceState(null, '', url); }, }, watch: { // Fires on every load()/loadOlder()/loadNewer()/jumpToYear()/ // enterYearMode()/exitYearMode() -- all of them reassign `rows` // (never mutate it in place), so a shallow watch catches every // window change without needing to hook each function // individually. See ensureLinkedReconciliationResolved() itself // for why this needs to run at all. rows() { this.ensureLinkedReconciliationResolved(); }, 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 is now pushed server-side (see LedgerRowsController/ // fetchFilteredLignes()) -- onFilterChanged() re-fetches the // currently loaded window with the new filter applied, then widens // it if that leaves too few rows to fill the viewport (see // ensureScrollable()). filterYear is handled separately above (it // swaps the whole loading strategy, not just which filter params // get sent). Each also reflects into the URL hash -- see syncHash(). filterCompte() { this.onFilterChanged(); }, filterClient() { this.onFilterChanged(); }, filterType() { this.onFilterChanged(); }, filterFlag() { this.onFilterChanged(); }, // Debounced (unlike the other filters above) -- this one changes on // every keystroke, and each change is a network round-trip now, // not a free client-side recompute. filterQ() { clearTimeout(this._filterQDebounce); this._filterQDebounce = setTimeout(() => this.onFilterChanged(), 350); }, onlyErrors() { this.onFilterChanged(); }, onlyFlagged() { this.onFilterChanged(); }, }, async mounted() { // Reproduce whatever the URL hash describes -- reload or a shared // link should land on the same filtered view. The basic filters are // all sent as query params on the very first load() below, so // setting them here first (before deciding how to load) means that // first fetch already reflects the restored view instead of // loading unfiltered and re-fetching a moment later; 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.filterFlag = hashState.filterFlag; this.filterQ = hashState.filterQ; this.onlyErrors = hashState.onlyErrors; this.onlyFlagged = hashState.onlyFlagged; 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(() => {}); fetchFlagNames().then((names) => { this.allFlagsList = names; }).catch(() => {}); fetchYearsList().then((years) => { this.allYearsList = years; }).catch(() => {}); fetchOuvertureEcarts().then((ecarts) => { this.ouvertureEcarts = ecarts; }).catch(() => {}); jQuery(document).on('dialog:afterclose', () => this.reloadWindow()); this.lastPollTs = Math.floor(Date.now() / 1000); this.startPolling(); }, }; 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) // Click-to-edit cells (type/client/facture/libellé) swap a // span for an /