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:
@@ -74,6 +74,13 @@ figli_compta_ledger.api_groupe_entree:
|
|||||||
node:
|
node:
|
||||||
type: entity: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:
|
figli_compta_ledger.api_dashboard_stats:
|
||||||
path: '/dashboard/api/stats'
|
path: '/dashboard/api/stats'
|
||||||
defaults:
|
defaults:
|
||||||
|
|||||||
@@ -2,20 +2,30 @@
|
|||||||
* @file
|
* @file
|
||||||
* Progressive decoupling: Drupal renders the page shell (nav, auth via
|
* Progressive decoupling: Drupal renders the page shell (nav, auth via
|
||||||
* session cookie, the "Ajouter une ligne" modal form); this Vue app fetches
|
* session cookie, the "Ajouter une ligne" modal form); this Vue app fetches
|
||||||
* JSON:API and renders a spreadsheet-like table of every ligne comptable,
|
* a spreadsheet-like table of every ligne comptable, with filters,
|
||||||
* with filters, month/year grouping, and per-row écart (répartition sum vs
|
* month/year grouping, and per-row écart (répartition sum vs montant HT)
|
||||||
* montant HT) highlighting -- inconsistencies are shown, not hidden.
|
* highlighting -- inconsistencies are shown, not hidden.
|
||||||
*
|
*
|
||||||
* Sliding window: with 5+ years of migrated history (~1500+ lines), loading
|
* Sliding window: with 5+ years of migrated history (~1500+ lines), loading
|
||||||
* everything up front took ~40s and made the tab briefly unresponsive.
|
* everything up front took ~40s and made the tab briefly unresponsive.
|
||||||
* Instead of one big fetch, `rows` only ever holds a date-range window
|
* Instead of one big fetch, `rows` only ever holds a date-range window
|
||||||
* (initially the ~18 months around today), extended by ~6 months whenever
|
* (initially the ~18 months around today), extended by ~6 months whenever
|
||||||
* the user scrolls near the top or bottom edge (IntersectionObserver on two
|
* the user scrolls near the top or bottom edge, and trimmed from the far
|
||||||
* sentinel rows), and trimmed from the far end once the window exceeds
|
* end once the window exceeds MAX_LOADED_MONTHS so it stays a genuine
|
||||||
* MAX_LOADED_MONTHS so it stays a genuine sliding buffer, not an
|
* sliding buffer, not an ever-growing list. The "Année" filter and the
|
||||||
* ever-growing list. The "Année" filter and the totals footer can't be
|
* totals footer can't be computed from a partial window, so they're backed
|
||||||
* computed from a partial window, so they're backed by their own small
|
* by their own small server endpoints (LedgerStatsController) instead.
|
||||||
* 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) {
|
(function (Drupal, Vue, jQuery) {
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -60,8 +70,7 @@
|
|||||||
// per year can search almost forever without ever surfacing more of
|
// per year can search almost forever without ever surfacing more of
|
||||||
// them. Comfortably wider than the whole migrated dataset (2021-today)
|
// them. Comfortably wider than the whole migrated dataset (2021-today)
|
||||||
// so a filter effectively gets the entire history to search, still
|
// so a filter effectively gets the entire history to search, still
|
||||||
// bounded (not literally unbounded memory) -- and DOM rendering cost
|
// bounded (not literally unbounded memory).
|
||||||
// stays proportional to filteredRows, not this raw fetched count.
|
|
||||||
const MAX_LOADED_MONTHS_FILTERED = 96;
|
const MAX_LOADED_MONTHS_FILTERED = 96;
|
||||||
// Hard cap on ensureScrollable()'s recursion (see below) -- comfortably
|
// Hard cap on ensureScrollable()'s recursion (see below) -- comfortably
|
||||||
// more than enough rounds to walk the entire MIN_LOADABLE_DATE..
|
// 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());
|
return (db.getFullYear() - da.getFullYear()) * 12 + (db.getMonth() - da.getMonth());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Builds the JSON:API URL for a half-open date range [start, end).
|
// Builds the query string for LedgerRowsController::index() (GET
|
||||||
function rangeUrl(start, end) {
|
// /lignes/api/lignes) -- the server-side-filtered replacement for
|
||||||
const filter =
|
// "fetch a JSON:API date range, then filter client-side": every
|
||||||
'filter[dateRange][group][conjunction]=AND' +
|
// toolbar filter is folded straight into the same request that loads
|
||||||
'&filter[gte][condition][path]=field_date_ligne' +
|
// the window, so `rows` only ever holds what's actually meant to be
|
||||||
'&filter[gte][condition][operator]=%3E%3D' +
|
// visible right now, pre-shaped exactly like buildRows() below would
|
||||||
'&filter[gte][condition][value]=' + start +
|
// have produced from JSON:API (no separate row-building step needed
|
||||||
'&filter[gte][condition][memberOf]=dateRange' +
|
// for this path). rangeOrAnnee is either {start, end} (the normal
|
||||||
'&filter[lt][condition][path]=field_date_ligne' +
|
// sliding window, and jumpToYear()) or {annee} (filterYear's
|
||||||
'&filter[lt][condition][operator]=%3C' +
|
// enterYearMode) -- the controller accepts either.
|
||||||
'&filter[lt][condition][value]=' + end +
|
function filteredLignesUrl(rangeOrAnnee, filters) {
|
||||||
'&filter[lt][condition][memberOf]=dateRange';
|
const params = new URLSearchParams();
|
||||||
return API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client,field_entree_liee,field_flag'
|
if (rangeOrAnnee.annee) {
|
||||||
+ '&page[limit]=50&sort=field_date_ligne,drupal_internal__nid&' + filter;
|
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) {
|
async function fetchLignes(url) {
|
||||||
@@ -301,6 +329,7 @@
|
|||||||
filterType: params.get('type') ? params.get('type').split(',') : [],
|
filterType: params.get('type') ? params.get('type').split(',') : [],
|
||||||
filterFlag: params.get('tag') ? params.get('tag').split(',') : [],
|
filterFlag: params.get('tag') ? params.get('tag').split(',') : [],
|
||||||
filterYear: params.get('annee') || '',
|
filterYear: params.get('annee') || '',
|
||||||
|
filterQ: params.get('q') || '',
|
||||||
onlyErrors: params.get('ecarts') === '1',
|
onlyErrors: params.get('ecarts') === '1',
|
||||||
onlyFlagged: params.get('signale') === '1',
|
onlyFlagged: params.get('signale') === '1',
|
||||||
aller: params.get('aller') || '',
|
aller: params.get('aller') || '',
|
||||||
@@ -314,6 +343,7 @@
|
|||||||
if (state.filterType.length) params.set('type', state.filterType.join(','));
|
if (state.filterType.length) params.set('type', state.filterType.join(','));
|
||||||
if (state.filterFlag.length) params.set('tag', state.filterFlag.join(','));
|
if (state.filterFlag.length) params.set('tag', state.filterFlag.join(','));
|
||||||
if (state.filterYear) params.set('annee', state.filterYear);
|
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.onlyErrors) params.set('ecarts', '1');
|
||||||
if (state.onlyFlagged) params.set('signale', '1');
|
if (state.onlyFlagged) params.set('signale', '1');
|
||||||
// Redundant/ambiguous alongside an active "Année" filter -- that
|
// Redundant/ambiguous alongside an active "Année" filter -- that
|
||||||
@@ -417,6 +447,11 @@
|
|||||||
// means "has any tag at all".
|
// means "has any tag at all".
|
||||||
filterFlag: [],
|
filterFlag: [],
|
||||||
filterYear: '',
|
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: '',
|
jumpYearValue: '',
|
||||||
// Not a filter itself (jumpYearValue always resets to '' right
|
// Not a filter itself (jumpYearValue always resets to '' right
|
||||||
// after firing) -- just remembers the last "Aller à" target so
|
// after firing) -- just remembers the last "Aller à" target so
|
||||||
@@ -604,21 +639,6 @@
|
|||||||
if (!this.filterEntreeGroup) return [];
|
if (!this.filterEntreeGroup) return [];
|
||||||
return this.allComptes.filter((c) => this.filterEntreeGroup.some((r) => r.parCompte[c] !== undefined));
|
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
|
// Totals for the entrée + linked sorties drill-down modal (see
|
||||||
// filterEntreeGroup above): the whole point of that view is "does
|
// filterEntreeGroup above): the whole point of that view is "does
|
||||||
// this entrée balance against what was paid out", so its own footer
|
// 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)])),
|
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() {
|
groupedRows() {
|
||||||
const list = this.filteredRows;
|
const list = this.rows;
|
||||||
if (this.groupBy === 'none') return list;
|
if (this.groupBy === 'none') return list;
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
for (const r of list) {
|
for (const r of list) {
|
||||||
@@ -972,21 +996,45 @@
|
|||||||
// that fall inside the current window get appended -- that's the
|
// that fall inside the current window get appended -- that's the
|
||||||
// one case that *does* need the array reassigned, since there's
|
// one case that *does* need the array reassigned, since there's
|
||||||
// no existing row object to mutate.
|
// 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) {
|
mergeChangedRows(changedRows) {
|
||||||
const isBeingEdited = (id) => this.editingTypeId === id || (this.editingCell && this.editingCell.id === id);
|
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 newOnes = [];
|
||||||
|
const removeIds = new Set();
|
||||||
for (const fresh of changedRows) {
|
for (const fresh of changedRows) {
|
||||||
if (isBeingEdited(fresh.id)) continue;
|
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) {
|
if (existing) {
|
||||||
|
if (matches) {
|
||||||
Object.assign(existing, fresh);
|
Object.assign(existing, fresh);
|
||||||
} else if (fresh.date >= this.windowStart && fresh.date < this.windowEnd) {
|
} else {
|
||||||
|
removeIds.add(fresh.id);
|
||||||
|
}
|
||||||
|
} else if (matches) {
|
||||||
newOnes.push(fresh);
|
newOnes.push(fresh);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (newOnes.length) {
|
if (newOnes.length || removeIds.size) {
|
||||||
this.rows = [...this.rows, ...newOnes].sort((a, b) => (a.date || '').localeCompare(b.date || ''));
|
this.rows = [...this.rows.filter((r) => !removeIds.has(r.id)), ...newOnes].sort((a, b) => (a.date || '').localeCompare(b.date || ''));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
startEditType(item) {
|
startEditType(item) {
|
||||||
@@ -1190,8 +1238,7 @@
|
|||||||
const today = todayStr();
|
const today = todayStr();
|
||||||
this.windowStart = addMonths(today, -WINDOW_MONTHS);
|
this.windowStart = addMonths(today, -WINDOW_MONTHS);
|
||||||
this.windowEnd = addMonths(today, WINDOW_MONTHS);
|
this.windowEnd = addMonths(today, WINDOW_MONTHS);
|
||||||
const { data, includedMap } = await fetchLignes(rangeUrl(this.windowStart, this.windowEnd));
|
this.rows = await fetchFilteredLignes({ start: this.windowStart, end: this.windowEnd }, this.currentFilters());
|
||||||
this.rows = buildRows(data, includedMap);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.error = err.message;
|
this.error = err.message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1200,12 +1247,14 @@
|
|||||||
},
|
},
|
||||||
// Post-edit refresh: re-fetch exactly the currently loaded window
|
// Post-edit refresh: re-fetch exactly the currently loaded window
|
||||||
// (not a fresh "last 18 months" window) so editing an old line
|
// (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() {
|
async reloadWindow() {
|
||||||
if (!this.windowStart || !this.windowEnd) return;
|
if (!this.windowStart || !this.windowEnd) return;
|
||||||
try {
|
try {
|
||||||
const { data, includedMap } = await fetchLignes(rangeUrl(this.windowStart, this.windowEnd));
|
this.rows = await fetchFilteredLignes({ start: this.windowStart, end: this.windowEnd }, this.currentFilters());
|
||||||
this.rows = buildRows(data, includedMap);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.error = err.message;
|
this.error = err.message;
|
||||||
}
|
}
|
||||||
@@ -1257,8 +1306,7 @@
|
|||||||
try {
|
try {
|
||||||
let newStart = addMonths(this.windowStart, -EXTEND_MONTHS);
|
let newStart = addMonths(this.windowStart, -EXTEND_MONTHS);
|
||||||
if (newStart < MIN_LOADABLE_DATE) newStart = MIN_LOADABLE_DATE;
|
if (newStart < MIN_LOADABLE_DATE) newStart = MIN_LOADABLE_DATE;
|
||||||
const { data, includedMap } = await fetchLignes(rangeUrl(newStart, this.windowStart));
|
const newRows = await fetchFilteredLignes({ start: newStart, end: this.windowStart }, this.currentFilters());
|
||||||
const newRows = buildRows(data, includedMap);
|
|
||||||
this.windowStart = newStart;
|
this.windowStart = newStart;
|
||||||
|
|
||||||
if (newRows.length) {
|
if (newRows.length) {
|
||||||
@@ -1298,8 +1346,7 @@
|
|||||||
try {
|
try {
|
||||||
let newEnd = addMonths(this.windowEnd, EXTEND_MONTHS);
|
let newEnd = addMonths(this.windowEnd, EXTEND_MONTHS);
|
||||||
if (newEnd > MAX_LOADABLE_DATE) newEnd = MAX_LOADABLE_DATE;
|
if (newEnd > MAX_LOADABLE_DATE) newEnd = MAX_LOADABLE_DATE;
|
||||||
const { data, includedMap } = await fetchLignes(rangeUrl(this.windowEnd, newEnd));
|
const newRows = await fetchFilteredLignes({ start: this.windowEnd, end: newEnd }, this.currentFilters());
|
||||||
const newRows = buildRows(data, includedMap);
|
|
||||||
this.windowEnd = newEnd;
|
this.windowEnd = newEnd;
|
||||||
|
|
||||||
if (newRows.length) {
|
if (newRows.length) {
|
||||||
@@ -1359,8 +1406,9 @@
|
|||||||
if ((notScrollable || wrap.scrollTop < threshold) && !this.loadingOlder) this.loadOlder();
|
if ((notScrollable || wrap.scrollTop < threshold) && !this.loadingOlder) this.loadOlder();
|
||||||
if ((notScrollable || wrap.scrollTop + wrap.clientHeight > wrap.scrollHeight - threshold) && !this.loadingNewer) this.loadNewer();
|
if ((notScrollable || wrap.scrollTop + wrap.clientHeight > wrap.scrollHeight - threshold) && !this.loadingNewer) this.loadNewer();
|
||||||
},
|
},
|
||||||
// Whether compte/client/type/écarts thin filteredRows down from
|
// Whether an active filter (server-side now, see
|
||||||
// whatever's actually loaded -- loadOlder()/loadNewer() use this to
|
// 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
|
// widen the trim cap (MAX_LOADED_MONTHS_FILTERED instead of
|
||||||
// MAX_LOADED_MONTHS), since the normal cap actively works against a
|
// MAX_LOADED_MONTHS), since the normal cap actively works against a
|
||||||
// sparse filter: extending one end and immediately trimming the
|
// 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
|
// filter with only a few matches a year could search almost forever
|
||||||
// without surfacing more of them.
|
// without surfacing more of them.
|
||||||
isFiltering() {
|
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
|
// Keeps extending the window (both directions) as long as a filter
|
||||||
// leaves too few matching rows to fill the viewport -- otherwise
|
// leaves too few matching rows to fill the viewport -- otherwise
|
||||||
@@ -1504,8 +1599,7 @@
|
|||||||
try {
|
try {
|
||||||
const start = year + '-01-01';
|
const start = year + '-01-01';
|
||||||
const end = (parseInt(year, 10) + 1) + '-01-01';
|
const end = (parseInt(year, 10) + 1) + '-01-01';
|
||||||
const { data, includedMap } = await fetchLignes(rangeUrl(start, end));
|
this.rows = await fetchFilteredLignes({ annee: year }, this.currentFilters());
|
||||||
this.rows = buildRows(data, includedMap);
|
|
||||||
this.windowStart = start;
|
this.windowStart = start;
|
||||||
this.windowEnd = end;
|
this.windowEnd = end;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1557,8 +1651,7 @@
|
|||||||
const yearStart = year + '-01-01';
|
const yearStart = year + '-01-01';
|
||||||
this.windowStart = yearStart;
|
this.windowStart = yearStart;
|
||||||
this.windowEnd = addMonths(yearStart, WINDOW_MONTHS * 2);
|
this.windowEnd = addMonths(yearStart, WINDOW_MONTHS * 2);
|
||||||
const { data, includedMap } = await fetchLignes(rangeUrl(this.windowStart, this.windowEnd));
|
this.rows = await fetchFilteredLignes({ start: this.windowStart, end: this.windowEnd }, this.currentFilters());
|
||||||
this.rows = buildRows(data, includedMap);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.error = err.message;
|
this.error = err.message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1582,6 +1675,7 @@
|
|||||||
filterType: this.filterType,
|
filterType: this.filterType,
|
||||||
filterFlag: this.filterFlag,
|
filterFlag: this.filterFlag,
|
||||||
filterYear: this.filterYear,
|
filterYear: this.filterYear,
|
||||||
|
filterQ: this.filterQ,
|
||||||
onlyErrors: this.onlyErrors,
|
onlyErrors: this.onlyErrors,
|
||||||
onlyFlagged: this.onlyFlagged,
|
onlyFlagged: this.onlyFlagged,
|
||||||
lastJumpYear: this.lastJumpYear,
|
lastJumpYear: this.lastJumpYear,
|
||||||
@@ -1612,49 +1706,54 @@
|
|||||||
}
|
}
|
||||||
this.syncHash();
|
this.syncHash();
|
||||||
},
|
},
|
||||||
// Any of these can thin filteredRows enough to remove the
|
// Any of these is now pushed server-side (see LedgerRowsController/
|
||||||
// scrollbar the sliding window relies on to keep loading -- see
|
// fetchFilteredLignes()) -- onFilterChanged() re-fetches the
|
||||||
// ensureScrollable(). filterYear is handled separately above (it
|
// currently loaded window with the new filter applied, then widens
|
||||||
// swaps the whole loading strategy, not just the visible subset).
|
// it if that leaves too few rows to fill the viewport (see
|
||||||
// Each also reflects into the URL hash so the filtered view is
|
// ensureScrollable()). filterYear is handled separately above (it
|
||||||
// reloadable/shareable -- see syncHash().
|
// swaps the whole loading strategy, not just which filter params
|
||||||
|
// get sent). Each also reflects into the URL hash -- see syncHash().
|
||||||
filterCompte() {
|
filterCompte() {
|
||||||
this.ensureScrollable();
|
this.onFilterChanged();
|
||||||
this.syncHash();
|
|
||||||
},
|
},
|
||||||
filterClient() {
|
filterClient() {
|
||||||
this.ensureScrollable();
|
this.onFilterChanged();
|
||||||
this.syncHash();
|
|
||||||
},
|
},
|
||||||
filterType() {
|
filterType() {
|
||||||
this.ensureScrollable();
|
this.onFilterChanged();
|
||||||
this.syncHash();
|
|
||||||
},
|
},
|
||||||
filterFlag() {
|
filterFlag() {
|
||||||
this.ensureScrollable();
|
this.onFilterChanged();
|
||||||
this.syncHash();
|
},
|
||||||
|
// 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() {
|
onlyErrors() {
|
||||||
this.ensureScrollable();
|
this.onFilterChanged();
|
||||||
this.syncHash();
|
|
||||||
},
|
},
|
||||||
onlyFlagged() {
|
onlyFlagged() {
|
||||||
this.ensureScrollable();
|
this.onFilterChanged();
|
||||||
this.syncHash();
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
// Reproduce whatever the URL hash describes -- reload or a shared
|
// Reproduce whatever the URL hash describes -- reload or a shared
|
||||||
// link should land on the same filtered view. The four basic
|
// link should land on the same filtered view. The basic filters are
|
||||||
// filters just narrow filteredRows, so they're safe to set before
|
// all sent as query params on the very first load() below, so
|
||||||
// deciding how to load; filterYear/aller each own their loading
|
// setting them here first (before deciding how to load) means that
|
||||||
// path (enterYearMode()/jumpToYear()), so at most one of those
|
// first fetch already reflects the restored view instead of
|
||||||
// runs instead of the default load().
|
// 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();
|
const hashState = readHashState();
|
||||||
this.filterCompte = hashState.filterCompte;
|
this.filterCompte = hashState.filterCompte;
|
||||||
this.filterClient = hashState.filterClient;
|
this.filterClient = hashState.filterClient;
|
||||||
this.filterType = hashState.filterType;
|
this.filterType = hashState.filterType;
|
||||||
this.filterFlag = hashState.filterFlag;
|
this.filterFlag = hashState.filterFlag;
|
||||||
|
this.filterQ = hashState.filterQ;
|
||||||
this.onlyErrors = hashState.onlyErrors;
|
this.onlyErrors = hashState.onlyErrors;
|
||||||
this.onlyFlagged = hashState.onlyFlagged;
|
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 shell: Drupal renders the page (nav, auth, permissions).
|
||||||
dashboard.js (Vue 3) fetches JSON:API and renders a spreadsheet-like table
|
home.js (Vue 3) renders a spreadsheet-like table of every ligne comptable,
|
||||||
of every ligne comptable, with filters and month/year grouping, client-side.
|
with filters and month/year grouping applied server-side (see
|
||||||
"Ajouter une ligne" opens the real Drupal node form in a modal
|
LedgerRowsController). "Ajouter une ligne" opens the real Drupal node form
|
||||||
(core/drupal.dialog.ajax) -- no form logic duplicated in JS.
|
in a modal (core/drupal.dialog.ajax) -- no form logic duplicated in JS.
|
||||||
#}
|
#}
|
||||||
<nav class="figli-page-nav">
|
<nav class="figli-page-nav">
|
||||||
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
|
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
|
||||||
@@ -44,6 +44,13 @@
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</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
|
<label>Type
|
||||||
<span class="figli-filter-row">
|
<span class="figli-filter-row">
|
||||||
<div class="figli-multiselect">
|
<div class="figli-multiselect">
|
||||||
@@ -110,7 +117,7 @@
|
|||||||
<option v-for="f in allFlagsList" :key="f" :value="f"></option>
|
<option v-for="f in allFlagsList" :key="f" :value="f"></option>
|
||||||
</datalist>
|
</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>
|
</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>
|
<p v-if="typeUpdateError" class="figli-error figli-inline-error">Erreur : {{ typeUpdateError }} <button type="button" class="figli-clear-drilldown" @click="typeUpdateError = null">✕</button></p>
|
||||||
|
|||||||
Reference in New Issue
Block a user