diff --git a/web/modules/custom/figli_compta_ledger/css/home.css b/web/modules/custom/figli_compta_ledger/css/home.css index f7b5684..d5f6da8 100644 --- a/web/modules/custom/figli_compta_ledger/css/home.css +++ b/web/modules/custom/figli_compta_ledger/css/home.css @@ -287,6 +287,18 @@ html.gin--dark-mode #figli-home-app { padding: 0.2rem 0.6rem; } +/* Sliding-window edge markers (IntersectionObserver targets) -- kept + short so they don't add visible dead space when idle, tall enough + (min-height) to reliably intersect the observer's root margin. */ +#figli-home-app tr.figli-sentinel-row td { + padding: 0.3rem 0.6rem; + min-height: 1.5rem; + text-align: center; + color: var(--figli-text-light); + font-size: 0.75rem; + font-style: italic; +} + #figli-home-app .figli-error { background: #fde8e8; border: 1px solid #f4a3a3; diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml index 72bd70a..5b37a50 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml @@ -33,3 +33,17 @@ figli_compta_ledger.link_entree: parameters: node: type: entity:node + +figli_compta_ledger.api_totaux_annee: + path: '/lignes/api/totaux' + defaults: + _controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::totauxAnnee' + requirements: + _permission: 'access content' + +figli_compta_ledger.api_annees: + path: '/lignes/api/annees' + defaults: + _controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::annees' + requirements: + _permission: 'access content' diff --git a/web/modules/custom/figli_compta_ledger/js/home.js b/web/modules/custom/figli_compta_ledger/js/home.js index 913f195..5eeef1d 100644 --- a/web/modules/custom/figli_compta_ledger/js/home.js +++ b/web/modules/custom/figli_compta_ledger/js/home.js @@ -5,6 +5,17 @@ * 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'; @@ -25,12 +36,47 @@ // (field_entree_liee) -- charge/autre/ouverture aren't client-specific. const LINKABLE_TYPES = ['versement', 'achat', 'hebergement']; - async function fetchAllLignes() { + 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 + + 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. - let url = API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client,field_entree_liee&page[limit]=50&sort=field_date_ligne,drupal_internal__nid'; const allData = []; const includedMap = new Map(); while (url) { @@ -47,6 +93,26 @@ return { data: dedup, includedMap }; } + async function fetchClientNames() { + const res = await fetch('/jsonapi/taxonomy_term/client?sort=name&page[limit]=200', { headers: { Accept: 'application/vnd.api+json' } }); + if (!res.ok) throw new Error('JSON:API a répondu ' + res.status); + const json = await res.json(); + return (json.data || []).map((t) => t.attributes.name).filter(Boolean).sort(); + } + + async function fetchYearsList() { + const res = await fetch('/lignes/api/annees', { headers: { Accept: 'application/json' } }); + if (!res.ok) throw new Error('/lignes/api/annees a répondu ' + res.status); + const json = await res.json(); + return json.annees || []; + } + + async function fetchYearTotals(annee) { + const res = await fetch('/lignes/api/totaux?annee=' + encodeURIComponent(annee), { headers: { Accept: 'application/json' } }); + if (!res.ok) throw new Error('/lignes/api/totaux a répondu ' + res.status); + return res.json(); + } + function resolve(includedMap, ref) { if (!ref) return null; return includedMap.get(ref.type + ':' + ref.id) || null; @@ -102,6 +168,8 @@ error: null, rows: [], allComptes: ['Sandrine', 'Maud', 'Ouidade', 'Chloé', 'Bachir', 'Valentin', 'EXT.', 'EXT.WEB'], + allClientsList: [], + allYearsList: [], filterCompte: '', filterClient: '', filterType: '', @@ -110,18 +178,22 @@ onlyErrors: false, hoverCol: null, filterEntreeId: null, + // Fenêtre glissante. + windowStart: null, + windowEnd: null, + 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 })); }, - allClients() { - return Array.from(new Set(this.rows.map((r) => r.client).filter(Boolean))).sort(); - }, - allYears() { - return Array.from(new Set(this.rows.map((r) => (r.date || '').slice(0, 4)).filter(Boolean))).sort().reverse(); - }, errorCount() { return this.rows.filter((r) => r.hasError).length; }, @@ -141,7 +213,9 @@ // 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. + // recomputed per template read. Limited to the currently loaded + // window -- a sortie linked to an entrée outside it won't be + // counted (accepted trade-off of the sliding window). reconciliationByEntree() { const map = new Map(); for (const entreeRow of this.rows) { @@ -172,20 +246,6 @@ } return map; }, - footerTotals() { - const parCompte = {}; - this.allComptes.forEach((c) => { parCompte[c] = 0; }); - let montantHt = 0, montantTtc = 0, ecart = 0; - for (const r of this.filteredRows) { - montantHt += r.montant_ht || 0; - montantTtc += r.montant_ttc || 0; - ecart += r.ecart || 0; - for (const c of this.allComptes) { - if (r.parCompte[c] !== undefined) parCompte[c] += r.parCompte[c]; - } - } - return { montant_ht: montantHt, montant_ttc: montantTtc, parCompte, ecart }; - }, filteredRows() { // Drill-down mode: an entrée and only the sorties linked to it, // ignoring the other filters -- clicking its badge again clears it. @@ -217,14 +277,14 @@ 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, rows: [] }); + 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 }); + 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; @@ -348,7 +408,10 @@ if (showLoading) this.loading = true; this.error = null; try { - const { data, includedMap } = await fetchAllLignes(); + 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; @@ -356,10 +419,203 @@ 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). + if (this.loadingOlder || !this.windowStart || this.filterYear) return; + this.loadingOlder = true; + try { + const newStart = addMonths(this.windowStart, -EXTEND_MONTHS); + 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, 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 loadNewer() { + if (this.loadingNewer || !this.windowEnd || this.filterYear) return; + this.loadingNewer = true; + try { + const newEnd = addMonths(this.windowEnd, EXTEND_MONTHS); + 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, -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; + if (wrap.scrollTop < threshold) this.loadOlder(); + if (wrap.scrollTop + wrap.clientHeight > wrap.scrollHeight - threshold) this.loadNewer(); + }, + // 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(); + } + }, + onScroll() { + if (this._scrollRaf) return; + this._scrollRaf = requestAnimationFrame(() => { + this._scrollRaf = null; + this.detectCurrentYear(); + this.checkEdges(); + }); + }, + 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.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.detectCurrentYear(); + }, }, - mounted() { - this.load(); - jQuery(document).on('dialog:afterclose', () => this.load(false)); + watch: { + filterYear(newYear, oldYear) { + if (newYear) { + this.enterYearMode(newYear); + } else if (oldYear) { + this.exitYearMode(); + } + }, + }, + async mounted() { + await this.load(); + // 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(() => {}); + jQuery(document).on('dialog:afterclose', () => this.reloadWindow()); + await this.$nextTick(); + const wrap = this.$refs.tableWrap; + if (wrap) { + wrap.addEventListener('scroll', this.onScroll, { passive: true }); + // 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(); }, }; diff --git a/web/modules/custom/figli_compta_ledger/src/Controller/LedgerStatsController.php b/web/modules/custom/figli_compta_ledger/src/Controller/LedgerStatsController.php new file mode 100644 index 0000000..d27060b --- /dev/null +++ b/web/modules/custom/figli_compta_ledger/src/Controller/LedgerStatsController.php @@ -0,0 +1,99 @@ +query->get('annee'); + if (!$annee || !preg_match('/^\d{4}$/', $annee)) { + return new JsonResponse(['error' => 'Paramètre "annee" invalide.'], 400); + } + + $storage = $this->entityTypeManager()->getStorage('node'); + $nids = $storage->getQuery() + ->accessCheck(TRUE) + ->condition('type', 'ligne_comptable') + ->condition('field_date_ligne', $annee . '-01-01', '>=') + ->condition('field_date_ligne', ((int) $annee + 1) . '-01-01', '<') + ->condition('field_type_ligne', 'ouverture', '<>') + ->execute(); + + $par_compte = []; + $montant_ht = 0.0; + $montant_ttc = 0.0; + $ecart = 0.0; + foreach ($storage->loadMultiple($nids) as $node) { + $ht = $node->hasField('field_montant_ht') && !$node->get('field_montant_ht')->isEmpty() + ? (float) $node->get('field_montant_ht')->value : 0.0; + $ttc = $node->hasField('field_montant_ttc') && !$node->get('field_montant_ttc')->isEmpty() + ? (float) $node->get('field_montant_ttc')->value : 0.0; + $montant_ht += $ht; + $montant_ttc += $ttc; + + $somme = 0.0; + foreach ($node->get('field_repartition')->referencedEntities() as $paragraph) { + if (!$paragraph->hasField('field_montant') || $paragraph->get('field_montant')->isEmpty()) { + continue; + } + $montant = (float) $paragraph->get('field_montant')->value; + $compte = $paragraph->get('field_compte')->entity ? $paragraph->get('field_compte')->entity->label() : NULL; + if ($compte) { + $par_compte[$compte] = ($par_compte[$compte] ?? 0) + $montant; + } + $somme += $montant; + } + $ecart += round($ht - $somme, 2); + } + + return new JsonResponse([ + 'annee' => $annee, + 'montant_ht' => round($montant_ht, 2), + 'montant_ttc' => round($montant_ttc, 2), + 'ecart' => round($ecart, 2), + 'par_compte' => array_map(fn ($v) => round($v, 2), $par_compte), + ]); + } + + /** + * GET /lignes/api/annees -- distinct years, most recent first, with at + * least 5 lines. The threshold exists specifically to keep the handful + * of mistyped historical dates (preserved as-is -- e.g. a "0213" typo + * for "2023") from polluting the year filter with bogus one-line + * "years". A plain SQL aggregate, not Entity API: this only needs the + * date column, not full node loads. + */ + public function annees() { + $connection = \Drupal::database(); + $query = $connection->select('node__field_date_ligne', 'd'); + $query->addExpression('SUBSTRING(d.field_date_ligne_value, 1, 4)', 'annee'); + $query->addExpression('COUNT(*)', 'total'); + $query->condition('d.bundle', 'ligne_comptable'); + $query->groupBy('annee'); + $query->having('COUNT(*) >= 5'); + $results = $query->execute()->fetchCol(); + + $annees = array_values($results); + rsort($annees); + + return new JsonResponse(['annees' => $annees]); + } + +} diff --git a/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig b/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig index cad309c..9e57b25 100644 --- a/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig +++ b/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig @@ -25,7 +25,7 @@ @@ -39,7 +39,7 @@ @@ -57,12 +57,12 @@ - {{ filteredRows.length }} / {{ rows.length }} lignes{{ errorCount ? ' — ' + errorCount + ' avec écart' : '' }} + {{ filteredRows.length }} / {{ rows.length }} lignes chargées{{ errorCount ? ' — ' + errorCount + ' avec écart' : '' }}
Chargement des données…
Erreur de chargement : {{ error }}
-| + Chargement des mois précédents… + | +|||||||||||||||||
| {{ item.label }} ({{ item.count }} lignes) | |||||||||||||||||
| + Chargement des mois suivants… + | +|||||||||||||||||
| - | Solde (créditeur / débiteur) — {{ filteredRows.length }} lignes filtrées | -{{ formatEur(footerTotals.montant_ht) }} | -{{ formatEur(footerTotals.montant_ttc) }} | -{{ formatEur(footerTotals.parCompte[c]) }} | -{{ formatEur(footerTotals.ecart) }} | ++ Solde {{ currentYear || '…' }} (créditeur / débiteur) + chargement… + | +{{ currentYearTotals ? formatEur(currentYearTotals.montant_ht) : '' }} | +{{ currentYearTotals ? formatEur(currentYearTotals.montant_ttc) : '' }} | +{{ currentYearTotals && currentYearTotals.par_compte[c] !== undefined ? formatEur(currentYearTotals.par_compte[c]) : '' }} | +{{ currentYearTotals ? formatEur(currentYearTotals.ecart) : '' }} | |||||||