Phase 2 : filtrage server-side de /lignes (compte, client, type, signalement, écarts, recherche libre)

Remplace le "charge tout puis filtre côté client" (filteredRows()) par un
nouvel endpoint /lignes/api/lignes (LedgerRowsController), qui pousse tous
les filtres de la barre d'outils dans une seule requête Entity/Field Query
API (pas de SQL brut) -- y compris à travers la relation
field_repartition -> field_compte (paragraph -> taxonomie), confirmé
fonctionner empiriquement avant d'écrire le contrôleur. `rows` ne contient
donc plus que ce qui est à la fois dans la fenêtre de dates ET dans les
filtres actifs ; la fenêtre glissante elle-même (loadOlder/loadNewer/
ensureScrollable) est inchangée, seul ce qui la peuple change.

Ajouts :
- Nouveau filtre "Libellé / Détails" (recherche plein texte sur
  field_notes ou le titre), débouncé côté client (350ms).
- filterYear (l'"Année" dédiée) passe par le même endpoint via son
  paramètre "annee".
- mergeChangedRows() (le merge du polling) tient maintenant compte des
  filtres actifs : une ligne qui ne correspond plus après un changement
  est retirée de `rows`, une ligne qui correspond nouvellement est ajoutée
  -- polling lui-même reste global/non filtré, seul le merge est
  filter-aware.

JSON:API reste utilisé pour ce que l'endroit filtré ne couvre pas : le
groupe entrée/sorties liées (fetchLignesByNids) et le polling
(fetchChangedSince), tous deux indépendants d'une plage de dates+filtres.

Testé en local : chaque filtre individuellement et combiné (compte+q,
client+type), widening de fenêtre sous filtre restrictif, restauration
combinée depuis le hash au reload, modale d'édition + reloadWindow après
fermeture, polling sans erreur.
This commit is contained in:
2026-09-08 14:04:35 +02:00
parent 7bcf65d25b
commit f7e7ae3265
4 changed files with 388 additions and 91 deletions
+185 -86
View File
@@ -2,20 +2,30 @@
* @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.
* 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.
* 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';
@@ -60,8 +70,7 @@
// 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.
// 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..
@@ -102,20 +111,39 @@
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,field_flag'
+ '&page[limit]=50&sort=field_date_ligne,drupal_internal__nid&' + filter;
// 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) {
@@ -301,6 +329,7 @@
filterType: params.get('type') ? params.get('type').split(',') : [],
filterFlag: params.get('tag') ? params.get('tag').split(',') : [],
filterYear: params.get('annee') || '',
filterQ: params.get('q') || '',
onlyErrors: params.get('ecarts') === '1',
onlyFlagged: params.get('signale') === '1',
aller: params.get('aller') || '',
@@ -314,6 +343,7 @@
if (state.filterType.length) params.set('type', state.filterType.join(','));
if (state.filterFlag.length) params.set('tag', state.filterFlag.join(','));
if (state.filterYear) params.set('annee', state.filterYear);
if (state.filterQ) params.set('q', state.filterQ);
if (state.onlyErrors) params.set('ecarts', '1');
if (state.onlyFlagged) params.set('signale', '1');
// Redundant/ambiguous alongside an active "Année" filter -- that
@@ -417,6 +447,11 @@
// means "has any tag at all".
filterFlag: [],
filterYear: '',
// Free-text match against Libellé/Détail (field_notes, falling
// back to the title) -- server-side (see LedgerRowsController),
// debounced client-side (see the filterQ watcher) so every
// keystroke doesn't fire its own request.
filterQ: '',
jumpYearValue: '',
// Not a filter itself (jumpYearValue always resets to '' right
// after firing) -- just remembers the last "Aller à" target so
@@ -604,21 +639,6 @@
if (!this.filterEntreeGroup) return [];
return this.allComptes.filter((c) => this.filterEntreeGroup.some((r) => r.parCompte[c] !== undefined));
},
filteredRows() {
return this.rows.filter((r) => {
// Several selected comptes/types match with OR semantics ("any
// of these") -- a line either touches one of the selected
// comptes or it doesn't, same idea for type.
if (this.filterCompte.length && !this.filterCompte.some((c) => r.parCompte[c] !== undefined)) return false;
if (this.filterClient && r.client !== this.filterClient) return false;
if (this.filterType.length && !this.filterType.includes(r.type)) return false;
if (this.filterFlag.length && !this.filterFlag.some((f) => r.flags.includes(f))) return false;
if (this.filterYear && (r.date || '').slice(0, 4) !== this.filterYear) return false;
if (this.onlyErrors && !r.hasError) return false;
if (this.onlyFlagged && !r.hasFlag) return false;
return true;
});
},
// Totals for the entrée + linked sorties drill-down modal (see
// filterEntreeGroup above): the whole point of that view is "does
// this entrée balance against what was paid out", so its own footer
@@ -649,8 +669,12 @@
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.filteredRows;
const list = this.rows;
if (this.groupBy === 'none') return list;
const groups = new Map();
for (const r of list) {
@@ -972,21 +996,45 @@
// 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 known = new Map([...this.rows, ...this.groupExtraRows].map((r) => [r.id, r]));
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 existing = known.get(fresh.id);
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) {
Object.assign(existing, fresh);
} else if (fresh.date >= this.windowStart && fresh.date < this.windowEnd) {
if (matches) {
Object.assign(existing, fresh);
} else {
removeIds.add(fresh.id);
}
} else if (matches) {
newOnes.push(fresh);
}
}
if (newOnes.length) {
this.rows = [...this.rows, ...newOnes].sort((a, b) => (a.date || '').localeCompare(b.date || ''));
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) {
@@ -1190,8 +1238,7 @@
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);
this.rows = await fetchFilteredLignes({ start: this.windowStart, end: this.windowEnd }, this.currentFilters());
} catch (err) {
this.error = err.message;
} finally {
@@ -1200,12 +1247,14 @@
},
// 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.
// 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 {
const { data, includedMap } = await fetchLignes(rangeUrl(this.windowStart, this.windowEnd));
this.rows = buildRows(data, includedMap);
this.rows = await fetchFilteredLignes({ start: this.windowStart, end: this.windowEnd }, this.currentFilters());
} catch (err) {
this.error = err.message;
}
@@ -1257,8 +1306,7 @@
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);
const newRows = await fetchFilteredLignes({ start: newStart, end: this.windowStart }, this.currentFilters());
this.windowStart = newStart;
if (newRows.length) {
@@ -1298,8 +1346,7 @@
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);
const newRows = await fetchFilteredLignes({ start: this.windowEnd, end: newEnd }, this.currentFilters());
this.windowEnd = newEnd;
if (newRows.length) {
@@ -1359,8 +1406,9 @@
if ((notScrollable || wrap.scrollTop < threshold) && !this.loadingOlder) this.loadOlder();
if ((notScrollable || wrap.scrollTop + wrap.clientHeight > wrap.scrollHeight - threshold) && !this.loadingNewer) this.loadNewer();
},
// Whether compte/client/type/écarts thin filteredRows down from
// whatever's actually loaded -- loadOlder()/loadNewer() use this to
// 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
@@ -1368,7 +1416,54 @@
// 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.onlyErrors || this.onlyFlagged);
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. 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.
onFilterChanged() {
this._queueWindowOp(() => this.reloadWindow().then(() => this.ensureScrollable()));
this.syncHash();
},
// Keeps extending the window (both directions) as long as a filter
// leaves too few matching rows to fill the viewport -- otherwise
@@ -1504,8 +1599,7 @@
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.rows = await fetchFilteredLignes({ annee: year }, this.currentFilters());
this.windowStart = start;
this.windowEnd = end;
} catch (err) {
@@ -1557,8 +1651,7 @@
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);
this.rows = await fetchFilteredLignes({ start: this.windowStart, end: this.windowEnd }, this.currentFilters());
} catch (err) {
this.error = err.message;
} finally {
@@ -1582,6 +1675,7 @@
filterType: this.filterType,
filterFlag: this.filterFlag,
filterYear: this.filterYear,
filterQ: this.filterQ,
onlyErrors: this.onlyErrors,
onlyFlagged: this.onlyFlagged,
lastJumpYear: this.lastJumpYear,
@@ -1612,49 +1706,54 @@
}
this.syncHash();
},
// Any of these can thin filteredRows enough to remove the
// scrollbar the sliding window relies on to keep loading -- see
// ensureScrollable(). filterYear is handled separately above (it
// swaps the whole loading strategy, not just the visible subset).
// Each also reflects into the URL hash so the filtered view is
// reloadable/shareable -- see syncHash().
// 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.ensureScrollable();
this.syncHash();
this.onFilterChanged();
},
filterClient() {
this.ensureScrollable();
this.syncHash();
this.onFilterChanged();
},
filterType() {
this.ensureScrollable();
this.syncHash();
this.onFilterChanged();
},
filterFlag() {
this.ensureScrollable();
this.syncHash();
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.ensureScrollable();
this.syncHash();
this.onFilterChanged();
},
onlyFlagged() {
this.ensureScrollable();
this.syncHash();
this.onFilterChanged();
},
},
async mounted() {
// Reproduce whatever the URL hash describes -- reload or a shared
// link should land on the same filtered view. The four basic
// filters just narrow filteredRows, so they're safe to set before
// deciding how to load; filterYear/aller each own their loading
// path (enterYearMode()/jumpToYear()), so at most one of those
// runs instead of the default load().
// 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;