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 6766ba8..3ebcca5 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 @@ -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: diff --git a/web/modules/custom/figli_compta_ledger/js/home.js b/web/modules/custom/figli_compta_ledger/js/home.js index 091beb1..9de192b 100644 --- a/web/modules/custom/figli_compta_ledger/js/home.js +++ b/web/modules/custom/figli_compta_ledger/js/home.js @@ -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; diff --git a/web/modules/custom/figli_compta_ledger/src/Controller/LedgerRowsController.php b/web/modules/custom/figli_compta_ledger/src/Controller/LedgerRowsController.php new file mode 100644 index 0000000..d66f1ef --- /dev/null +++ b/web/modules/custom/figli_compta_ledger/src/Controller/LedgerRowsController.php @@ -0,0 +1,184 @@ +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, + ]; + } + +} 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 8711051..0e1b7e8 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 @@ -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. #}