Files
drupal-figli-compta/web/modules/custom/figli_compta_ledger/js/home.js
T
bachir 1ab38bc2a7 Highlight versements not (fully) backed by their entrée
For "versement freelance" rows specifically: an amber outline plus a
badge when the versement either isn't linked to any entrée client at
all ("Non liée"), or is linked but reconciliationByEntree still shows
a residual on that entrée ("Reste à verser" / "Sur-versé", with the
amount in the tooltip). The residual belongs to the entrée as a whole,
not to any one sortie -- when several versements share an entrée, each
shows the same aggregate figure, since there's no way to attribute the
shortfall to one specific payment. No highlight when the linked entrée
isn't in the currently loaded window (can't tell either way -- same
accepted trade-off as reconciliationByEntree itself).

Verified: filtering to "Versement freelance" shows 21 of 25 loaded
rows flagged (17 unlinked, 4 with a reste-à-verser residual), the
remaining 4 fully-reconciled rows correctly unflagged, and the amber
outline resolves correctly in dark mode.
2026-09-05 10:40:01 +02:00

916 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file
* Progressive decoupling: Drupal renders the page shell (nav, auth via
* session cookie, the "Ajouter une ligne" modal form); this Vue app fetches
* JSON:API and renders a spreadsheet-like table of every ligne comptable,
* with filters, month/year grouping, and per-row écart (répartition sum vs
* montant HT) highlighting -- inconsistencies are shown, not hidden.
*
* Sliding window: with 5+ years of migrated history (~1500+ lines), loading
* everything up front took ~40s and made the tab briefly unresponsive.
* Instead of one big fetch, `rows` only ever holds a date-range window
* (initially the ~18 months around today), extended by ~6 months whenever
* the user scrolls near the top or bottom edge (IntersectionObserver on two
* sentinel rows), and trimmed from the far end once the window exceeds
* MAX_LOADED_MONTHS so it stays a genuine sliding buffer, not an
* ever-growing list. The "Année" filter and the totals footer can't be
* computed from a partial window, so they're backed by their own small
* server endpoints (LedgerStatsController) instead.
*/
(function (Drupal, Vue, jQuery) {
'use strict';
const API_BASE = '/jsonapi/node/ligne_comptable';
const EUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' });
const MONTHS = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
const TYPE_LABELS = {
entree: 'Entrée client',
charge: 'Charge structurelle',
versement: 'Versement freelance',
achat: 'Achat client',
hebergement: 'Hébergement',
sous_traitant: 'Sous-traitant',
salaire_stage: 'Salaire / stage',
charges_local_pro: 'Charges local pro',
autre: 'Autre',
ouverture: 'Ouverture',
};
// Sorties that can be linked to the entrée client they pay out against
// (field_entree_liee) -- charge/autre/ouverture/salaire_stage/
// charges_local_pro aren't client-specific.
const LINKABLE_TYPES = ['versement', 'achat', 'hebergement', 'sous_traitant'];
const WINDOW_MONTHS = 9; // each side of "today", for the initial load
const EXTEND_MONTHS = 6; // increment per scroll-triggered load
const MAX_LOADED_MONTHS = 30; // trim the far end once the window exceeds this
// While a thinning filter (compte/client/type/écarts) is active, the cap
// above is actively counter-productive: extending one end and trimming
// the other nets out to roughly the same 30-month slice every round
// (see ensureScrollable()), so a filter with only a handful of matches
// per year can search almost forever without ever surfacing more of
// them. Comfortably wider than the whole migrated dataset (2021-today)
// so a filter effectively gets the entire history to search, still
// bounded (not literally unbounded memory) -- and DOM rendering cost
// stays proportional to filteredRows, not this raw fetched count.
const MAX_LOADED_MONTHS_FILTERED = 96;
// Hard cap on ensureScrollable()'s recursion (see below) -- comfortably
// more than enough rounds to walk the entire MIN_LOADABLE_DATE..
// MAX_LOADABLE_DATE span at EXTEND_MONTHS per round.
const MAX_ENSURE_SCROLLABLE_ROUNDS = 30;
// Absolute bounds on how far the window can extend. Without these,
// loadOlder()/loadNewer() kept pushing windowStart/windowEnd outward
// even when a fetch came back empty (e.g. extending into future dates
// with no data yet) -- since nothing about the table's height or
// scroll position changes when 0 rows come back, checkEdges() stayed
// satisfied and the next scroll/layout tick fired the exact same load
// again, drifting further out forever with no way to stop. Comfortably
// before the earliest migrated year (2021) and well past any plausible
// future-dated entry.
const MIN_LOADABLE_DATE = '2020-06-01';
const MAX_LOADABLE_DATE = addMonths(todayStr(), 24);
function todayStr() {
return new Date().toISOString().slice(0, 10);
}
function addMonths(dateStr, n) {
const d = new Date(dateStr + 'T00:00:00');
d.setMonth(d.getMonth() + n);
return d.toISOString().slice(0, 10);
}
function monthsBetween(a, b) {
const da = new Date(a + 'T00:00:00');
const db = new Date(b + 'T00:00:00');
return (db.getFullYear() - da.getFullYear()) * 12 + (db.getMonth() - da.getMonth());
}
// Builds the JSON:API URL for a half-open date range [start, end).
function rangeUrl(start, end) {
const filter =
'filter[dateRange][group][conjunction]=AND' +
'&filter[gte][condition][path]=field_date_ligne' +
'&filter[gte][condition][operator]=%3E%3D' +
'&filter[gte][condition][value]=' + start +
'&filter[gte][condition][memberOf]=dateRange' +
'&filter[lt][condition][path]=field_date_ligne' +
'&filter[lt][condition][operator]=%3C' +
'&filter[lt][condition][value]=' + end +
'&filter[lt][condition][memberOf]=dateRange';
return API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client,field_entree_liee'
+ '&page[limit]=50&sort=field_date_ligne,drupal_internal__nid&' + filter;
}
async function fetchLignes(url) {
// sort includes drupal_internal__nid as a tie-breaker: field_date_ligne
// alone is not unique (many lines share a date), and without a unique
// secondary sort key, offset pagination can silently duplicate or skip
// rows across pages.
const allData = [];
const includedMap = new Map();
while (url) {
const res = await fetch(url, { headers: { Accept: 'application/vnd.api+json' } });
if (!res.ok) throw new Error('JSON:API a répondu ' + res.status);
const json = await res.json();
allData.push(...(json.data || []));
(json.included || []).forEach((item) => includedMap.set(item.type + ':' + item.id, item));
url = json.links && json.links.next ? json.links.next.href : null;
}
// Defensive de-dup by node id, in case pagination ever repeats a row.
const seen = new Set();
const dedup = allData.filter((n) => (seen.has(n.id) ? false : (seen.add(n.id), true)));
return { data: dedup, includedMap };
}
async function fetchClientNames() {
const res = await fetch('/jsonapi/taxonomy_term/client?sort=name&page[limit]=200', { headers: { Accept: 'application/vnd.api+json' } });
if (!res.ok) throw new Error('JSON:API a répondu ' + res.status);
const json = await res.json();
return (json.data || []).map((t) => t.attributes.name).filter(Boolean).sort();
}
async function fetchYearsList() {
const res = await fetch('/lignes/api/annees', { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error('/lignes/api/annees a répondu ' + res.status);
const json = await res.json();
return json.annees || [];
}
// { "2023": { "Bachir": -0.03, ... }, ... } -- years where the actual
// ouverture doesn't match the previous year's calculated closing
// balance (that year's own ouverture + every movement dated within
// it). Small dataset (a handful of year boundaries), fetched once.
async function fetchOuvertureEcarts() {
const res = await fetch('/lignes/api/reconciliation-ouverture', { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error('/lignes/api/reconciliation-ouverture a répondu ' + res.status);
return res.json();
}
// POST /lignes/{nid}/type -- change field_type_ligne without opening the
// full edit form. Fetches a fresh CSRF token each time (core's
// /session/token) rather than caching one -- this is an occasional
// action, not worth the complexity of handling a stale cached token.
async function updateLigneType(nid, type) {
const tokenRes = await fetch('/session/token');
const token = await tokenRes.text();
const res = await fetch('/lignes/' + nid + '/type', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token },
body: JSON.stringify({ type }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json.error || ('/lignes/' + nid + '/type a répondu ' + res.status));
return json;
}
async function fetchYearTotals(annee) {
const res = await fetch('/lignes/api/totaux?annee=' + encodeURIComponent(annee), { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error('/lignes/api/totaux a répondu ' + res.status);
return res.json();
}
function resolve(includedMap, ref) {
if (!ref) return null;
return includedMap.get(ref.type + ':' + ref.id) || null;
}
function buildRows(data, includedMap) {
const rows = [];
for (const node of data) {
const rels = node.relationships || {};
const attrs = node.attributes;
const clientTerm = resolve(includedMap, rels.field_client && rels.field_client.data);
const entreeLieeNode = resolve(includedMap, rels.field_entree_liee && rels.field_entree_liee.data);
const parCompte = {};
let somme = 0;
const repartitionRefs = (rels.field_repartition && rels.field_repartition.data) || [];
for (const ref of repartitionRefs) {
const paragraph = resolve(includedMap, ref);
if (!paragraph) continue;
const montant = parseFloat(paragraph.attributes.field_montant || 0);
const compteTerm = resolve(includedMap, paragraph.relationships && paragraph.relationships.field_compte && paragraph.relationships.field_compte.data);
const compteName = compteTerm ? compteTerm.attributes.name : '(compte inconnu)';
parCompte[compteName] = (parCompte[compteName] || 0) + montant;
somme += montant;
}
const montantHt = attrs.field_montant_ht !== null && attrs.field_montant_ht !== undefined ? parseFloat(attrs.field_montant_ht) : null;
const ecart = montantHt !== null ? Math.round((montantHt - somme) * 100) / 100 : 0;
rows.push({
id: node.id,
nid: attrs.drupal_internal__nid,
date: attrs.field_date_ligne,
type: attrs.field_type_ligne,
client: clientTerm ? clientTerm.attributes.name : null,
libelle: attrs.field_notes || attrs.title,
montant_ht: montantHt,
montant_ttc: attrs.field_montant_ttc !== null && attrs.field_montant_ttc !== undefined ? parseFloat(attrs.field_montant_ttc) : null,
parCompte,
somme,
ecart,
hasError: Math.abs(ecart) > 0.01,
linkable: LINKABLE_TYPES.includes(attrs.field_type_ligne),
entreeLieeId: entreeLieeNode ? entreeLieeNode.id : null,
entreeLieeLabel: entreeLieeNode ? (entreeLieeNode.attributes.title || null) : null,
});
}
rows.sort((a, b) => (a.date || '').localeCompare(b.date || ''));
return rows;
}
const App = {
data() {
return {
loading: true,
error: null,
rows: [],
allComptes: ['Sandrine', 'Maud', 'Ouidade', 'Chloé', 'Bachir', 'Valentin', 'EXT.', 'EXT.WEB'],
allClientsList: [],
allYearsList: [],
ouvertureEcarts: {},
filterCompte: '',
filterClient: '',
filterType: '',
filterYear: '',
jumpYearValue: '',
groupBy: 'month',
onlyErrors: false,
hoverCol: null,
filterEntreeId: null,
// Which row's type badge is currently showing its inline <select>
// instead of the badge -- only one at a time.
editingTypeId: null,
typeUpdateError: null,
// Fenêtre glissante.
windowStart: null,
windowEnd: null,
loadingOlder: false,
loadingNewer: false,
// True while EITHER loadOlder() or loadNewer() is running -- they
// both mutate `rows`/windowStart/windowEnd, and a scroll-position
// compensation write inside one (from trimming the far end) fires
// a real 'scroll' event that can kick off the other direction
// while the first is still in flight. Without a shared lock, the
// two interleave and race on the same state -- observed as `rows`
// transiently emptying out (table blinks away) before settling.
// loadingOlder/loadingNewer stay separate for the sentinel-row
// text; this gates actual execution.
loadingWindow: false,
// Footer : totaux de l'année actuellement visible à l'écran (pas
// de la fenêtre chargée), voir detectCurrentYear().
currentYear: null,
currentYearTotals: null,
currentYearLoading: false,
};
},
computed: {
allTypes() {
return Object.entries(TYPE_LABELS).map(([value, label]) => ({ value, label }));
},
errorCount() {
return this.rows.filter((r) => r.hasError).length;
},
sortiesByEntree() {
const map = new Map();
for (const r of this.rows) {
if (!r.entreeLieeId) continue;
if (!map.has(r.entreeLieeId)) map.set(r.entreeLieeId, []);
map.get(r.entreeLieeId).push(r);
}
return map;
},
// Compares each entrée's répartition (positive shares owed) against
// the combined répartition of every sortie linked to it (negative
// amounts paid out), per compte -- both sides can be split across
// several comptes. A residual near zero means fully settled;
// positive means still owed ("reste à verser"); negative means more
// was paid out than the entrée allocated ("sur-versé", worth a
// closer look). Precomputed once for all entrée rows rather than
// recomputed per template read. Limited to the currently loaded
// window -- a sortie linked to an entrée outside it won't be
// counted (accepted trade-off of the sliding window).
reconciliationByEntree() {
const map = new Map();
for (const entreeRow of this.rows) {
if (entreeRow.type !== 'entree') continue;
const linked = this.sortiesByEntree.get(entreeRow.id) || [];
const versementsParCompte = {};
for (const s of linked) {
for (const [compte, montant] of Object.entries(s.parCompte)) {
versementsParCompte[compte] = (versementsParCompte[compte] || 0) + montant;
}
}
const comptes = new Set([...Object.keys(entreeRow.parCompte), ...Object.keys(versementsParCompte)]);
let resteAVerser = 0;
let surVerse = 0;
const detail = [];
for (const c of comptes) {
const residual = Math.round(((entreeRow.parCompte[c] || 0) + (versementsParCompte[c] || 0)) * 100) / 100;
if (residual > 0.01) resteAVerser += residual;
else if (residual < -0.01) surVerse += -residual;
if (Math.abs(residual) > 0.01) detail.push(c + ' : ' + this.formatEur(residual));
}
map.set(entreeRow.id, {
count: linked.length,
resteAVerser: Math.round(resteAVerser * 100) / 100,
surVerse: Math.round(surVerse * 100) / 100,
detail: detail.join(', ') || 'Entièrement soldé',
});
}
return map;
},
filteredRows() {
// Drill-down mode: an entrée and only the sorties linked to it,
// ignoring the other filters -- clicking its badge again clears it.
if (this.filterEntreeId) {
const entree = this.rows.find((r) => r.id === this.filterEntreeId);
const linked = this.sortiesByEntree.get(this.filterEntreeId) || [];
return entree ? [entree, ...linked] : linked;
}
return this.rows.filter((r) => {
if (this.filterCompte && r.parCompte[this.filterCompte] === undefined) return false;
if (this.filterClient && r.client !== this.filterClient) return false;
if (this.filterType && r.type !== this.filterType) return false;
if (this.filterYear && (r.date || '').slice(0, 4) !== this.filterYear) return false;
if (this.onlyErrors && !r.hasError) return false;
return true;
});
},
groupedRows() {
const list = this.filteredRows;
if (this.groupBy === 'none') return list;
const groups = new Map();
for (const r of list) {
const d = r.date ? new Date(r.date + 'T00:00:00') : null;
let key, label;
if (this.groupBy === 'year') {
key = d ? String(d.getFullYear()) : '?';
label = key;
} else {
key = d ? d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') : '?';
label = d ? MONTHS[d.getMonth()] + ' ' + d.getFullYear() : 'Date inconnue';
}
if (!groups.has(key)) groups.set(key, { label, year: d ? String(d.getFullYear()) : null, rows: [] });
groups.get(key).rows.push(r);
}
const sortedKeys = Array.from(groups.keys()).sort();
const out = [];
for (const key of sortedKeys) {
const g = groups.get(key);
out.push({ isGroup: true, key: 'g-' + key, label: g.label, count: g.rows.length, year: g.year });
for (const r of g.rows) out.push(Object.assign({ key: 'r-' + r.id }, r));
}
return out;
},
},
methods: {
formatEur(v) {
return v === null || v === undefined ? '' : EUR.format(v);
},
// É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 };
},
// Flags a "versement freelance" row that isn't (fully) backed by
// the entrée client it pays out against: either not linked at all,
// or linked but reconciliationByEntree still shows a residual on
// that entrée. The residual belongs to the entrée as a whole, not
// to any one sortie -- when several versements share an entrée,
// each shows the same aggregate residual, since there's no way to
// say which specific one is "the" shortfall. Null (no highlight)
// when the linked entrée isn't in the currently loaded window --
// same accepted trade-off as reconciliationByEntree itself.
versementStatus(item) {
if (item.type !== 'versement') return null;
if (!item.entreeLieeId) {
return { kind: 'non-liee', detail: 'Aucune entrée client liée.' };
}
const recon = this.reconciliationByEntree.get(item.entreeLieeId);
if (!recon) return null;
if (recon.resteAVerser > 0.01) {
return { kind: 'reste', detail: 'Reste à verser sur lentrée liée : ' + this.formatEur(recon.resteAVerser) };
}
if (recon.surVerse > 0.01) {
return { kind: 'sur-verse', detail: 'Sur-versé sur lentrée liée : ' + this.formatEur(recon.surVerse) };
}
return null;
},
versementStatusLabel(kind) {
if (kind === 'non-liee') return 'Non liée';
if (kind === 'reste') return 'Reste à verser';
return 'Sur-versé';
},
// jj/mm/aa -- shorter than the API's ISO yyyy-mm-dd, saves column
// width in a table already packed with 8 compte columns.
formatDate(iso) {
if (!iso) return '';
const [y, m, d] = iso.split('-');
return d + '/' + m + '/' + y.slice(2);
},
typeLabel(t) {
return TYPE_LABELS[t] || t;
},
soldeClass(v) {
if (v > 0.5) return 'figli-solde-crediteur';
if (v < -0.5) return 'figli-solde-debiteur';
return '';
},
// Single colored anchor per row (Montant HT only, not every compte
// column) so entrées/sorties are scannable at a glance without the
// table turning into a red/green garland. No 0.5€ threshold like
// soldeClass -- individual lines are often small (e.g. a -1.07€ OVH
// renewal), any nonzero sign should read as positive/negative.
montantClass(v) {
if (v > 0) return 'figli-montant-positif';
if (v < 0) return 'figli-montant-negatif';
return '';
},
openAddForm() {
Drupal.ajax({
url: '/node/add/ligne_comptable',
dialogType: 'modal',
dialog: { width: 800, title: 'Ajouter une ligne comptable' },
progress: { type: 'throbber' },
}).execute();
},
openEditForm(nid) {
Drupal.ajax({
url: '/node/' + nid + '/edit',
dialogType: 'modal',
dialog: { width: 800, title: 'Modifier la ligne comptable' },
progress: { type: 'throbber' },
}).execute();
},
openLinkForm(nid) {
Drupal.ajax({
url: '/lignes/' + nid + '/lier',
dialogType: 'modal',
dialog: { width: 500, title: 'Lier à une entrée client' },
progress: { type: 'throbber' },
}).execute();
},
toggleEntreeFilter(id) {
this.filterEntreeId = this.filterEntreeId === id ? null : id;
},
startEditType(item) {
this.typeUpdateError = null;
this.editingTypeId = item.id;
},
// Optimistically patches the row in `rows` (not the transient
// `item` from groupedRows -- that object is rebuilt from scratch on
// every computed re-evaluation, so mutating it wouldn't stick) for
// instant feedback, then reconciles with the server in the
// background via reloadWindow() (picks up anything else the save
// touched, e.g. a cleared field_entree_liee).
async saveType(item, event) {
const newType = event.target.value;
this.editingTypeId = null;
if (newType === item.type) return;
try {
const result = await updateLigneType(item.nid, newType);
const row = this.rows.find((r) => r.id === item.id);
if (row) {
row.type = newType;
row.linkable = LINKABLE_TYPES.includes(newType);
if (result.entree_liee_cleared) {
row.entreeLieeId = null;
row.entreeLieeLabel = null;
}
}
this.reloadWindow();
} catch (err) {
this.typeUpdateError = err.message;
}
},
onCellHover(evt) {
const cell = evt.target.closest('td, th');
if (!cell) return;
// No crosshair on the technical action columns (link/edit icons) --
// there's nothing to compare across rows there.
if (cell.classList.contains('actions-col')) {
this.clearColHover();
return;
}
// Logical column position, not DOM sibling index: the totals row's
// first cell has colspan="4", which shifts every cell.cellIndex
// after it out of alignment with the body rows.
const index = this.logicalColIndex(cell);
if (index === this.hoverCol) return;
this.setColHover(index);
},
clearColHover() {
this.setColHover(null);
},
logicalColIndex(cell) {
let index = 0;
let sib = cell.previousElementSibling;
while (sib) {
index += sib.colSpan || 1;
sib = sib.previousElementSibling;
}
return index;
},
// Whole-column highlight (header + body + footer) to pair with the
// row hover: plain DOM class toggling rather than a Vue-bound class
// per cell, since the column count/order is fixed markup here, not
// data-driven -- no need to thread an index through every <td>.
setColHover(index) {
const table = this.$refs.tableEl;
if (!table) return;
table.querySelectorAll('.figli-col-hover').forEach((el) => el.classList.remove('figli-col-hover'));
this.hoverCol = index;
if (index === null) return;
table.querySelectorAll('tr').forEach((tr) => {
let pos = 0;
for (const cell of tr.children) {
const span = cell.colSpan || 1;
if (index >= pos && index < pos + span) {
cell.classList.add('figli-col-hover');
break;
}
pos += span;
}
});
},
// showLoading defaults to true for the initial mount, where there's
// nothing on screen yet to preserve. A post-edit refresh passes
// false: flipping `loading` back to true would unmount the whole
// v-else table (the "Chargement…" paragraph takes its place) and
// remount it from scratch once the fetch resolves -- exactly the
// full-table flicker/scroll-reset Vue's keyed diffing exists to
// avoid. Reassigning `rows` in place lets Vue patch just the rows
// that actually changed.
async load(showLoading = true) {
if (showLoading) this.loading = true;
this.error = null;
try {
const today = todayStr();
this.windowStart = addMonths(today, -WINDOW_MONTHS);
this.windowEnd = addMonths(today, WINDOW_MONTHS);
const { data, includedMap } = await fetchLignes(rangeUrl(this.windowStart, this.windowEnd));
this.rows = buildRows(data, includedMap);
} catch (err) {
this.error = err.message;
} finally {
if (showLoading) this.loading = false;
}
},
// Post-edit refresh: re-fetch exactly the currently loaded window
// (not a fresh "last 18 months" window) so editing an old line
// doesn't silently reset how far the user had scrolled back.
async reloadWindow() {
if (!this.windowStart || !this.windowEnd) return;
try {
const { data, includedMap } = await fetchLignes(rangeUrl(this.windowStart, this.windowEnd));
this.rows = buildRows(data, includedMap);
} catch (err) {
this.error = err.message;
}
},
async loadOlder() {
// Bypassed while a specific "Année" filter is active -- that mode
// loads exactly one year and nothing else (see enterYearMode).
// Also bypassed once the window already reaches MIN_LOADABLE_DATE --
// nothing older to fetch, so skip straight out instead of re-firing
// an empty request on every subsequent scroll/layout tick.
if (this.loadingWindow || !this.windowStart || this.filterYear || this.windowStart <= MIN_LOADABLE_DATE) return;
this.loadingWindow = true;
this.loadingOlder = true;
try {
let newStart = addMonths(this.windowStart, -EXTEND_MONTHS);
if (newStart < MIN_LOADABLE_DATE) newStart = MIN_LOADABLE_DATE;
const { data, includedMap } = await fetchLignes(rangeUrl(newStart, this.windowStart));
const newRows = buildRows(data, includedMap);
this.windowStart = newStart;
if (newRows.length) {
const wrap = this.$refs.tableWrap;
const prevScrollHeight = wrap ? wrap.scrollHeight : 0;
this.rows = [...newRows, ...this.rows];
await this.$nextTick();
// Prepending pushes existing content down -- keep whatever
// the user was looking at in the same visual spot.
if (wrap) wrap.scrollTop += wrap.scrollHeight - prevScrollHeight;
}
// Trim the far (newer) end once the window's grown past the cap
// -- happens below the current scroll position, so no visual
// jump to compensate for.
const maxEnd = addMonths(this.windowStart, this.isFiltering() ? MAX_LOADED_MONTHS_FILTERED : MAX_LOADED_MONTHS);
if (this.windowEnd > maxEnd) {
this.rows = this.rows.filter((r) => r.date < maxEnd);
this.windowEnd = maxEnd;
}
} catch (err) {
this.error = err.message;
} finally {
this.loadingOlder = false;
this.loadingWindow = false;
}
},
async loadNewer() {
// Same MIN_LOADABLE_DATE reasoning as loadOlder(), mirrored at the
// future end.
if (this.loadingWindow || !this.windowEnd || this.filterYear || this.windowEnd >= MAX_LOADABLE_DATE) return;
this.loadingWindow = true;
this.loadingNewer = true;
try {
let newEnd = addMonths(this.windowEnd, EXTEND_MONTHS);
if (newEnd > MAX_LOADABLE_DATE) newEnd = MAX_LOADABLE_DATE;
const { data, includedMap } = await fetchLignes(rangeUrl(this.windowEnd, newEnd));
const newRows = buildRows(data, includedMap);
this.windowEnd = newEnd;
if (newRows.length) {
// Appended below the viewport -- no scroll compensation needed.
this.rows = [...this.rows, ...newRows];
}
// Trim the far (older) end once past the cap -- this IS above
// the viewport, so compensate scrollTop the same way loadOlder
// does for its prepend.
const minStart = addMonths(this.windowEnd, this.isFiltering() ? -MAX_LOADED_MONTHS_FILTERED : -MAX_LOADED_MONTHS);
if (this.windowStart < minStart) {
const wrap = this.$refs.tableWrap;
const prevScrollHeight = wrap ? wrap.scrollHeight : 0;
const prevScrollTop = wrap ? wrap.scrollTop : 0;
this.rows = this.rows.filter((r) => r.date >= minStart);
this.windowStart = minStart;
await this.$nextTick();
if (wrap) wrap.scrollTop = prevScrollTop - (prevScrollHeight - wrap.scrollHeight);
}
} catch (err) {
this.error = err.message;
} finally {
this.loadingNewer = false;
this.loadingWindow = false;
}
},
// Scroll-position based rather than IntersectionObserver: one
// unified scroll handler drives both edge-loading and
// detectCurrentYear() below, instead of maintaining two separate
// observer instances for what's fundamentally the same "where is
// the user looking right now" question.
checkEdges() {
const wrap = this.$refs.tableWrap;
if (!wrap) return;
const threshold = 400;
// No overflow at all -- typically a filter (compte/client/type/
// écarts) has thinned the visible rows enough that the loaded
// window's worth of matches doesn't fill the viewport. scrollTop
// is stuck at 0 either way, so neither of the two checks below
// means anything on its own; try both directions instead of
// waiting for a scroll that can never happen with nothing to
// scroll.
const notScrollable = wrap.scrollHeight <= wrap.clientHeight;
if (notScrollable || wrap.scrollTop < threshold) this.loadOlder();
if (notScrollable || wrap.scrollTop + wrap.clientHeight > wrap.scrollHeight - threshold) this.loadNewer();
},
// Whether compte/client/type/écarts thin filteredRows down from
// whatever's actually loaded -- loadOlder()/loadNewer() use this to
// widen the trim cap (MAX_LOADED_MONTHS_FILTERED instead of
// MAX_LOADED_MONTHS), since the normal cap actively works against a
// sparse filter: extending one end and immediately trimming the
// other off nets out to almost the same slice every round, so a
// filter with only a few matches a year could search almost forever
// without surfacing more of them.
isFiltering() {
return !!(this.filterCompte || this.filterClient || this.filterType || this.onlyErrors);
},
// Keeps extending the window (both directions) as long as a filter
// leaves too few matching rows to fill the viewport -- otherwise
// there's never another scroll event to hang further loading off
// of, and the sliding window silently stops responding the moment a
// filter (compte/client/type/écarts) makes the visible page short
// enough to not need a scrollbar.
//
// Bounded by a round count regardless, as a safety net: addMonths()
// uses JS's Date#setMonth(), which isn't invertible for month-end
// dates (e.g. 2025-03-31 minus 1 month lands on 2025-03-03, not
// 2025-02-28, because setMonth(1) overflows February), so even with
// the widened cap above, "no change since last round" isn't a
// guaranteed way to detect "there's genuinely nothing more to find".
async ensureScrollable(round = 0) {
if (this.filterYear || round >= MAX_ENSURE_SCROLLABLE_ROUNDS) return;
const wrap = this.$refs.tableWrap;
if (!wrap) return;
await this.$nextTick();
if (wrap.scrollHeight > wrap.clientHeight) return;
const beforeStart = this.windowStart;
const beforeEnd = this.windowEnd;
await this.loadOlder();
await this.loadNewer();
if (this.windowStart === beforeStart && this.windowEnd === beforeEnd) return;
await this.ensureScrollable(round + 1);
},
// Which year is "at the top" of the visible area right now (just
// under the sticky thead) -- drives the totals footer. Works
// regardless of grouping mode: every rendered row (group header or
// data row) carries a data-year attribute.
detectCurrentYear() {
const wrap = this.$refs.tableWrap;
const table = this.$refs.tableEl;
if (!wrap || !table) return;
const thead = table.querySelector('thead');
const wrapRect = wrap.getBoundingClientRect();
const thresholdY = wrapRect.top + (thead ? thead.getBoundingClientRect().height : 0) + 2;
const rows = table.querySelectorAll('tbody tr[data-year]');
let year = null;
for (const tr of rows) {
const rect = tr.getBoundingClientRect();
if (rect.bottom > thresholdY) {
year = tr.dataset.year;
break;
}
}
if (year && year !== this.currentYear) {
this.currentYear = year;
this.loadCurrentYearTotals();
}
},
onScroll() {
if (this._scrollRaf) return;
this._scrollRaf = requestAnimationFrame(() => {
this._scrollRaf = null;
this.detectCurrentYear();
this.checkEdges();
});
},
// mounted() only runs once, but `v-if="loading"` swaps out the
// <div ref="tableWrap"> for a fresh DOM node every time loading
// toggles true -> false (enterYearMode/exitYearMode/jumpToYear all
// do this) -- the old node's scroll listener goes with it, silently
// leaving the new one with no listener at all (checkEdges() and
// detectCurrentYear(), and so the footer, stop responding to
// scroll). Call this after every such transition, once the new
// wrap exists; the dataset flag makes it a no-op if the element is
// unchanged.
ensureScrollListener() {
const wrap = this.$refs.tableWrap;
if (wrap && !wrap.dataset.figliScrollBound) {
wrap.dataset.figliScrollBound = '1';
wrap.addEventListener('scroll', this.onScroll, { passive: true });
}
},
async loadCurrentYearTotals() {
if (!this.currentYear) return;
this.currentYearLoading = true;
try {
this.currentYearTotals = await fetchYearTotals(this.currentYear);
} catch (err) {
this.currentYearTotals = null;
} finally {
this.currentYearLoading = false;
}
},
// Picking a specific "Année" bypasses the sliding window entirely --
// load exactly that year (a bounded, small fetch on its own) rather
// than post-filtering whatever happens to be in the current window,
// which could easily be empty for a year the user hasn't scrolled
// to yet.
async enterYearMode(year) {
this.loading = true;
this.error = null;
try {
const start = year + '-01-01';
const end = (parseInt(year, 10) + 1) + '-01-01';
const { data, includedMap } = await fetchLignes(rangeUrl(start, end));
this.rows = buildRows(data, includedMap);
this.windowStart = start;
this.windowEnd = end;
} catch (err) {
this.error = err.message;
} finally {
this.loading = false;
}
await this.$nextTick();
if (this.$refs.tableWrap) this.$refs.tableWrap.scrollTop = 0;
this.ensureScrollListener();
this.detectCurrentYear();
},
// Back to "Toutes" -- resume the normal sliding window, re-centered
// on today rather than trying to resume exactly where the year
// filter was applied from.
async exitYearMode() {
await this.load();
await this.$nextTick();
const wrap = this.$refs.tableWrap;
if (wrap) wrap.scrollTop = wrap.scrollHeight;
this.ensureScrollListener();
this.detectCurrentYear();
},
// "Aller à" -- a one-shot navigation shortcut, distinct from the
// "Année" filter: it re-centers the sliding window on 1 January of
// the chosen year and scrolls there, but (unlike filterYear) doesn't
// restrict the view to just that year -- scrolling up/down still
// extends the window normally, so you can still see the overlap
// with neighbouring years.
onJumpYearChange() {
const year = this.jumpYearValue;
this.jumpYearValue = ''; // reset immediately -- this is an action, not a sticky filter
this.jumpToYear(year);
},
async jumpToYear(year) {
if (!year) return;
// A year filter would otherwise fight with the window this sets
// below -- clear it first, silencing the watcher's own
// exitYearMode() so its (different) window doesn't race this one.
if (this.filterYear) {
this._skipYearWatch = true;
this.filterYear = '';
}
this.loading = true;
this.error = null;
try {
const yearStart = year + '-01-01';
this.windowStart = yearStart;
this.windowEnd = addMonths(yearStart, WINDOW_MONTHS * 2);
const { data, includedMap } = await fetchLignes(rangeUrl(this.windowStart, this.windowEnd));
this.rows = buildRows(data, includedMap);
} catch (err) {
this.error = err.message;
} finally {
this.loading = false;
}
await this.$nextTick();
const wrap = this.$refs.tableWrap;
if (wrap) wrap.scrollTop = 0;
this.ensureScrollListener();
this.detectCurrentYear();
this.checkEdges();
},
},
watch: {
filterYear(newYear, oldYear) {
if (this._skipYearWatch) {
this._skipYearWatch = false;
return;
}
if (newYear) {
this.enterYearMode(newYear);
} else if (oldYear) {
this.exitYearMode();
}
},
// Any of these can thin filteredRows enough to remove the
// scrollbar the sliding window relies on to keep loading -- see
// ensureScrollable(). filterYear is handled separately above (it
// swaps the whole loading strategy, not just the visible subset).
filterCompte() {
this.ensureScrollable();
},
filterClient() {
this.ensureScrollable();
},
filterType() {
this.ensureScrollable();
},
onlyErrors() {
this.ensureScrollable();
},
},
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(() => {});
fetchOuvertureEcarts().then((ecarts) => { this.ouvertureEcarts = ecarts; }).catch(() => {});
jQuery(document).on('dialog:afterclose', () => this.reloadWindow());
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();
},
};
Drupal.behaviors.figliComptaHome = {
attach(context) {
const root = context.querySelector ? context.querySelector('#figli-home-app') : null;
if (root && !root.dataset.figliInitialized) {
root.dataset.figliInitialized = '1';
Vue.createApp(App).mount(root);
}
},
};
})(Drupal, Vue, jQuery);