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
@@ -74,6 +74,13 @@ figli_compta_ledger.api_groupe_entree:
node:
type: entity:node
figli_compta_ledger.api_lignes:
path: '/lignes/api/lignes'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerRowsController::index'
requirements:
_permission: 'access content'
figli_compta_ledger.api_dashboard_stats:
path: '/dashboard/api/stats'
defaults:
+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;
@@ -0,0 +1,184 @@
<?php
namespace Drupal\figli_compta_ledger\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\node\NodeInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
/**
* GET /lignes/api/lignes -- server-side filtered replacement for the old
* "load a date-range window via JSON:API, then filter client-side"
* approach in home.js. Every toolbar filter (compte, client, type,
* signalement, écarts, recherche libre) is pushed into a single Drupal
* Entity/Field Query API query -- not raw SQL, so entity access checks
* apply natively -- rather than fetching everything in range and
* discarding what doesn't match on the client. Confirmed empirically
* (drush php:eval against dev) that a condition can traverse
* field_repartition (entity_reference_revisions to paragraphs) into the
* paragraph's own field_compte (entity_reference to taxonomy_term) as a
* dotted relationship path -- that was the one open technical question
* before writing this. Date range (or "annee" in its place) stays
* required -- the sliding window itself isn't going away, only what
* populates it.
*/
class LedgerRowsController extends ControllerBase {
/**
* Mirrors LedgerActionsController::LINKABLE_TYPES.
*/
const LINKABLE_TYPES = ['versement', 'achat', 'hebergement', 'sous_traitant'];
public function index(Request $request) {
$annee = $request->query->get('annee');
if ($annee) {
if (!preg_match('/^\d{4}$/', $annee)) {
return new JsonResponse(['error' => 'Paramètre "annee" invalide.'], 400);
}
$start = $annee . '-01-01';
$end = ((int) $annee + 1) . '-01-01';
}
else {
$start = $request->query->get('start');
$end = $request->query->get('end');
if (!$start || !$end || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $start) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $end)) {
return new JsonResponse(['error' => 'Paramètres "start"/"end" invalides.'], 400);
}
}
$storage = $this->entityTypeManager()->getStorage('node');
$query = $storage->getQuery()
->accessCheck(TRUE)
->condition('type', 'ligne_comptable')
->condition('field_date_ligne', $start, '>=')
->condition('field_date_ligne', $end, '<')
->sort('field_date_ligne')
->sort('nid');
$compte = array_filter(explode(',', (string) $request->query->get('compte', '')));
if ($compte) {
$tids = $this->termIdsByNames('compte', $compte);
// No matching term at all (typo, renamed compte) still runs the
// query with an impossible condition rather than short-circuiting
// to an empty response -- fails the same visible "0 rows" way as
// an ordinary empty date range, instead of a silent special case.
$query->condition('field_repartition.entity.field_compte.target_id', $tids ?: [0], 'IN');
}
$client = trim((string) $request->query->get('client', ''));
if ($client !== '') {
$tids = $this->termIdsByNames('client', [$client]);
$query->condition('field_client', $tids ?: [0], 'IN');
}
$type = array_filter(explode(',', (string) $request->query->get('type', '')));
if ($type) {
$query->condition('field_type_ligne', array_values($type), 'IN');
}
$flag = array_filter(explode(',', (string) $request->query->get('flag', '')));
if ($flag) {
$tids = $this->termIdsByNames('flag', $flag);
$query->condition('field_flag.target_id', $tids ?: [0], 'IN');
}
$q = trim((string) $request->query->get('q', ''));
if ($q !== '') {
// Mirrors buildRows()'s `libelle: attrs.field_notes || attrs.title`
// fallback in home.js -- a line with no notes shows its title, so
// the search has to match either, not just field_notes.
$group = $query->orConditionGroup()
->condition('field_notes', $q, 'CONTAINS')
->condition('title', $q, 'CONTAINS');
$query->condition($group);
}
if ($request->query->get('ecarts') === '1') {
$query->condition('field_ecart', 0, '<>');
}
if ($request->query->get('signale') === '1') {
$query->exists('field_flag');
}
$nids = $query->execute();
$rows = [];
foreach ($storage->loadMultiple($nids) as $node) {
$rows[] = $this->serializeRow($node);
}
return new JsonResponse(['rows' => $rows]);
}
/**
* Resolves taxonomy term names to ids within a given vocabulary --
* shared by the compte/client/flag filters above. Silently drops names
* that don't match anything (the caller falls back to an impossible
* [0] condition rather than treating "no match" as "no filter").
*/
private function termIdsByNames(string $vid, array $names): array {
if (!$names) {
return [];
}
$tids = $this->entityTypeManager()->getStorage('taxonomy_term')->getQuery()
->accessCheck(FALSE)
->condition('vid', $vid)
->condition('name', array_values($names), 'IN')
->execute();
return array_values($tids);
}
/**
* Same row shape as buildRows() in home.js builds client-side from
* JSON:API, so the frontend can treat rows from either source
* identically. `id` is the node's UUID (what JSON:API exposes as
* node.id and every row-matching-by-id in home.js keys on), not the
* integer nid.
*/
private function serializeRow(NodeInterface $node): array {
$parCompte = [];
$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() : '(compte inconnu)';
$parCompte[$compte] = ($parCompte[$compte] ?? 0) + $montant;
$somme += $montant;
}
$ecart = $node->hasField('field_ecart') && !$node->get('field_ecart')->isEmpty()
? (float) $node->get('field_ecart')->value : 0.0;
$entreeLieeNodes = $node->hasField('field_entree_liee') ? $node->get('field_entree_liee')->referencedEntities() : [];
$flagTerms = $node->hasField('field_flag') ? $node->get('field_flag')->referencedEntities() : [];
$type = $node->get('field_type_ligne')->value;
return [
'id' => $node->uuid(),
'nid' => (int) $node->id(),
'changed' => date(DATE_ATOM, $node->getChangedTime()),
'date' => $node->get('field_date_ligne')->value,
'type' => $type,
'client' => $node->get('field_client')->entity ? $node->get('field_client')->entity->label() : NULL,
'facture' => $node->get('field_numero_facture')->value ?: NULL,
'libelle' => $node->get('field_notes')->value ?: $node->getTitle(),
'montant_ht' => $node->get('field_montant_ht')->isEmpty() ? NULL : (float) $node->get('field_montant_ht')->value,
'cotisation' => $node->hasField('field_cotisation_urssaf') && !$node->get('field_cotisation_urssaf')->isEmpty() ? (float) $node->get('field_cotisation_urssaf')->value : NULL,
'tva' => $node->hasField('field_tva') && !$node->get('field_tva')->isEmpty() ? (float) $node->get('field_tva')->value : NULL,
'montant_ttc' => $node->hasField('field_montant_ttc') && !$node->get('field_montant_ttc')->isEmpty() ? (float) $node->get('field_montant_ttc')->value : NULL,
'parCompte' => (object) $parCompte,
'somme' => $somme,
'ecart' => $ecart,
'hasError' => abs($ecart) > 0.01,
'linkable' => in_array($type, self::LINKABLE_TYPES, TRUE),
'entreeLieeIds' => array_map(fn ($n) => $n->uuid(), $entreeLieeNodes),
'entreeLieeLabels' => array_map(fn ($n) => $n->getTitle() ?: $n->uuid(), $entreeLieeNodes),
'flags' => array_map(fn ($t) => $t->label(), $flagTerms),
'hasFlag' => count($flagTerms) > 0,
];
}
}
@@ -1,9 +1,9 @@
{#
Dashboard shell: Drupal renders the page (nav, auth, permissions).
dashboard.js (Vue 3) fetches JSON:API and renders a spreadsheet-like table
of every ligne comptable, with filters and month/year grouping, client-side.
"Ajouter une ligne" opens the real Drupal node form in a modal
(core/drupal.dialog.ajax) -- no form logic duplicated in JS.
home.js (Vue 3) renders a spreadsheet-like table of every ligne comptable,
with filters and month/year grouping applied server-side (see
LedgerRowsController). "Ajouter une ligne" opens the real Drupal node form
in a modal (core/drupal.dialog.ajax) -- no form logic duplicated in JS.
#}
<nav class="figli-page-nav">
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
@@ -44,6 +44,13 @@
</span>
</label>
<label>Libellé / Détails
<span class="figli-filter-row">
<input type="text" v-model="filterQ" placeholder="Rechercher…" autocomplete="off" class="figli-client-input" />
<button v-if="filterQ" type="button" class="figli-filter-clear" @click="filterQ = ''" title="Effacer ce filtre">✕</button>
</span>
</label>
<label>Type
<span class="figli-filter-row">
<div class="figli-multiselect">
@@ -110,7 +117,7 @@
<option v-for="f in allFlagsList" :key="f" :value="f"></option>
</datalist>
<span class="figli-count" v-if="!loading">{{ filteredRows.length }} / {{ rows.length }} lignes chargées{{ errorCount ? ' — ' + errorCount + ' avec écart' : '' }}</span>
<span class="figli-count" v-if="!loading">{{ rows.length }} ligne{{ rows.length > 1 ? 's' : '' }} chargée{{ rows.length > 1 ? 's' : '' }}{{ errorCount ? ' — ' + errorCount + ' avec écart' : '' }}</span>
</div>
<p v-if="typeUpdateError" class="figli-error figli-inline-error">Erreur : {{ typeUpdateError }} <button type="button" class="figli-clear-drilldown" @click="typeUpdateError = null">✕</button></p>