Sliding-window loading for the grand livre + year-scoped sticky footer
With 5+ years of migrated history, loading every ligne up front took ~40s. The table now only ever holds a date-range window (~18 months around today by default), extended by 6 months when scrolling near either edge and trimmed from the far end past a 30-month cap. The totals footer and "Année" filter can't be answered from a partial window, so they're backed by two new small endpoints (LedgerStatsController) instead: per-compte totals for whichever year is currently scrolled into view, and the distinct list of years with data.
This commit is contained in:
@@ -287,6 +287,18 @@ html.gin--dark-mode #figli-home-app {
|
||||
padding: 0.2rem 0.6rem;
|
||||
}
|
||||
|
||||
/* Sliding-window edge markers (IntersectionObserver targets) -- kept
|
||||
short so they don't add visible dead space when idle, tall enough
|
||||
(min-height) to reliably intersect the observer's root margin. */
|
||||
#figli-home-app tr.figli-sentinel-row td {
|
||||
padding: 0.3rem 0.6rem;
|
||||
min-height: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--figli-text-light);
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#figli-home-app .figli-error {
|
||||
background: #fde8e8;
|
||||
border: 1px solid #f4a3a3;
|
||||
|
||||
@@ -33,3 +33,17 @@ figli_compta_ledger.link_entree:
|
||||
parameters:
|
||||
node:
|
||||
type: entity:node
|
||||
|
||||
figli_compta_ledger.api_totaux_annee:
|
||||
path: '/lignes/api/totaux'
|
||||
defaults:
|
||||
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::totauxAnnee'
|
||||
requirements:
|
||||
_permission: 'access content'
|
||||
|
||||
figli_compta_ledger.api_annees:
|
||||
path: '/lignes/api/annees'
|
||||
defaults:
|
||||
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::annees'
|
||||
requirements:
|
||||
_permission: 'access content'
|
||||
|
||||
@@ -5,6 +5,17 @@
|
||||
* JSON:API and renders a spreadsheet-like table of every ligne comptable,
|
||||
* with filters, month/year grouping, and per-row écart (répartition sum vs
|
||||
* montant HT) highlighting -- inconsistencies are shown, not hidden.
|
||||
*
|
||||
* Sliding window: with 5+ years of migrated history (~1500+ lines), loading
|
||||
* everything up front took ~40s and made the tab briefly unresponsive.
|
||||
* Instead of one big fetch, `rows` only ever holds a date-range window
|
||||
* (initially the ~18 months around today), extended by ~6 months whenever
|
||||
* the user scrolls near the top or bottom edge (IntersectionObserver on two
|
||||
* sentinel rows), and trimmed from the far end once the window exceeds
|
||||
* MAX_LOADED_MONTHS so it stays a genuine sliding buffer, not an
|
||||
* ever-growing list. The "Année" filter and the totals footer can't be
|
||||
* computed from a partial window, so they're backed by their own small
|
||||
* server endpoints (LedgerStatsController) instead.
|
||||
*/
|
||||
(function (Drupal, Vue, jQuery) {
|
||||
'use strict';
|
||||
@@ -25,12 +36,47 @@
|
||||
// (field_entree_liee) -- charge/autre/ouverture aren't client-specific.
|
||||
const LINKABLE_TYPES = ['versement', 'achat', 'hebergement'];
|
||||
|
||||
async function fetchAllLignes() {
|
||||
const WINDOW_MONTHS = 9; // each side of "today", for the initial load
|
||||
const EXTEND_MONTHS = 6; // increment per scroll-triggered load
|
||||
const MAX_LOADED_MONTHS = 30; // trim the far end once the window exceeds this
|
||||
|
||||
function todayStr() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addMonths(dateStr, n) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
d.setMonth(d.getMonth() + n);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function monthsBetween(a, b) {
|
||||
const da = new Date(a + 'T00:00:00');
|
||||
const db = new Date(b + 'T00:00:00');
|
||||
return (db.getFullYear() - da.getFullYear()) * 12 + (db.getMonth() - da.getMonth());
|
||||
}
|
||||
|
||||
// Builds the JSON:API URL for a half-open date range [start, end).
|
||||
function rangeUrl(start, end) {
|
||||
const filter =
|
||||
'filter[dateRange][group][conjunction]=AND' +
|
||||
'&filter[gte][condition][path]=field_date_ligne' +
|
||||
'&filter[gte][condition][operator]=%3E%3D' +
|
||||
'&filter[gte][condition][value]=' + start +
|
||||
'&filter[gte][condition][memberOf]=dateRange' +
|
||||
'&filter[lt][condition][path]=field_date_ligne' +
|
||||
'&filter[lt][condition][operator]=%3C' +
|
||||
'&filter[lt][condition][value]=' + end +
|
||||
'&filter[lt][condition][memberOf]=dateRange';
|
||||
return API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client,field_entree_liee'
|
||||
+ '&page[limit]=50&sort=field_date_ligne,drupal_internal__nid&' + filter;
|
||||
}
|
||||
|
||||
async function fetchLignes(url) {
|
||||
// sort includes drupal_internal__nid as a tie-breaker: field_date_ligne
|
||||
// alone is not unique (many lines share a date), and without a unique
|
||||
// secondary sort key, offset pagination can silently duplicate or skip
|
||||
// rows across pages.
|
||||
let url = API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client,field_entree_liee&page[limit]=50&sort=field_date_ligne,drupal_internal__nid';
|
||||
const allData = [];
|
||||
const includedMap = new Map();
|
||||
while (url) {
|
||||
@@ -47,6 +93,26 @@
|
||||
return { data: dedup, includedMap };
|
||||
}
|
||||
|
||||
async function fetchClientNames() {
|
||||
const res = await fetch('/jsonapi/taxonomy_term/client?sort=name&page[limit]=200', { headers: { Accept: 'application/vnd.api+json' } });
|
||||
if (!res.ok) throw new Error('JSON:API a répondu ' + res.status);
|
||||
const json = await res.json();
|
||||
return (json.data || []).map((t) => t.attributes.name).filter(Boolean).sort();
|
||||
}
|
||||
|
||||
async function fetchYearsList() {
|
||||
const res = await fetch('/lignes/api/annees', { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('/lignes/api/annees a répondu ' + res.status);
|
||||
const json = await res.json();
|
||||
return json.annees || [];
|
||||
}
|
||||
|
||||
async function fetchYearTotals(annee) {
|
||||
const res = await fetch('/lignes/api/totaux?annee=' + encodeURIComponent(annee), { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('/lignes/api/totaux a répondu ' + res.status);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function resolve(includedMap, ref) {
|
||||
if (!ref) return null;
|
||||
return includedMap.get(ref.type + ':' + ref.id) || null;
|
||||
@@ -102,6 +168,8 @@
|
||||
error: null,
|
||||
rows: [],
|
||||
allComptes: ['Sandrine', 'Maud', 'Ouidade', 'Chloé', 'Bachir', 'Valentin', 'EXT.', 'EXT.WEB'],
|
||||
allClientsList: [],
|
||||
allYearsList: [],
|
||||
filterCompte: '',
|
||||
filterClient: '',
|
||||
filterType: '',
|
||||
@@ -110,18 +178,22 @@
|
||||
onlyErrors: false,
|
||||
hoverCol: null,
|
||||
filterEntreeId: null,
|
||||
// Fenêtre glissante.
|
||||
windowStart: null,
|
||||
windowEnd: null,
|
||||
loadingOlder: false,
|
||||
loadingNewer: false,
|
||||
// Footer : totaux de l'année actuellement visible à l'écran (pas
|
||||
// de la fenêtre chargée), voir detectCurrentYear().
|
||||
currentYear: null,
|
||||
currentYearTotals: null,
|
||||
currentYearLoading: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
allTypes() {
|
||||
return Object.entries(TYPE_LABELS).map(([value, label]) => ({ value, label }));
|
||||
},
|
||||
allClients() {
|
||||
return Array.from(new Set(this.rows.map((r) => r.client).filter(Boolean))).sort();
|
||||
},
|
||||
allYears() {
|
||||
return Array.from(new Set(this.rows.map((r) => (r.date || '').slice(0, 4)).filter(Boolean))).sort().reverse();
|
||||
},
|
||||
errorCount() {
|
||||
return this.rows.filter((r) => r.hasError).length;
|
||||
},
|
||||
@@ -141,7 +213,9 @@
|
||||
// positive means still owed ("reste à verser"); negative means more
|
||||
// was paid out than the entrée allocated ("sur-versé", worth a
|
||||
// closer look). Precomputed once for all entrée rows rather than
|
||||
// recomputed per template read.
|
||||
// recomputed per template read. Limited to the currently loaded
|
||||
// window -- a sortie linked to an entrée outside it won't be
|
||||
// counted (accepted trade-off of the sliding window).
|
||||
reconciliationByEntree() {
|
||||
const map = new Map();
|
||||
for (const entreeRow of this.rows) {
|
||||
@@ -172,20 +246,6 @@
|
||||
}
|
||||
return map;
|
||||
},
|
||||
footerTotals() {
|
||||
const parCompte = {};
|
||||
this.allComptes.forEach((c) => { parCompte[c] = 0; });
|
||||
let montantHt = 0, montantTtc = 0, ecart = 0;
|
||||
for (const r of this.filteredRows) {
|
||||
montantHt += r.montant_ht || 0;
|
||||
montantTtc += r.montant_ttc || 0;
|
||||
ecart += r.ecart || 0;
|
||||
for (const c of this.allComptes) {
|
||||
if (r.parCompte[c] !== undefined) parCompte[c] += r.parCompte[c];
|
||||
}
|
||||
}
|
||||
return { montant_ht: montantHt, montant_ttc: montantTtc, parCompte, ecart };
|
||||
},
|
||||
filteredRows() {
|
||||
// Drill-down mode: an entrée and only the sorties linked to it,
|
||||
// ignoring the other filters -- clicking its badge again clears it.
|
||||
@@ -217,14 +277,14 @@
|
||||
key = d ? d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') : '?';
|
||||
label = d ? MONTHS[d.getMonth()] + ' ' + d.getFullYear() : 'Date inconnue';
|
||||
}
|
||||
if (!groups.has(key)) groups.set(key, { label, rows: [] });
|
||||
if (!groups.has(key)) groups.set(key, { label, year: d ? String(d.getFullYear()) : null, rows: [] });
|
||||
groups.get(key).rows.push(r);
|
||||
}
|
||||
const sortedKeys = Array.from(groups.keys()).sort();
|
||||
const out = [];
|
||||
for (const key of sortedKeys) {
|
||||
const g = groups.get(key);
|
||||
out.push({ isGroup: true, key: 'g-' + key, label: g.label, count: g.rows.length });
|
||||
out.push({ isGroup: true, key: 'g-' + key, label: g.label, count: g.rows.length, year: g.year });
|
||||
for (const r of g.rows) out.push(Object.assign({ key: 'r-' + r.id }, r));
|
||||
}
|
||||
return out;
|
||||
@@ -348,7 +408,10 @@
|
||||
if (showLoading) this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const { data, includedMap } = await fetchAllLignes();
|
||||
const today = todayStr();
|
||||
this.windowStart = addMonths(today, -WINDOW_MONTHS);
|
||||
this.windowEnd = addMonths(today, WINDOW_MONTHS);
|
||||
const { data, includedMap } = await fetchLignes(rangeUrl(this.windowStart, this.windowEnd));
|
||||
this.rows = buildRows(data, includedMap);
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
@@ -356,10 +419,203 @@
|
||||
if (showLoading) this.loading = false;
|
||||
}
|
||||
},
|
||||
// Post-edit refresh: re-fetch exactly the currently loaded window
|
||||
// (not a fresh "last 18 months" window) so editing an old line
|
||||
// doesn't silently reset how far the user had scrolled back.
|
||||
async reloadWindow() {
|
||||
if (!this.windowStart || !this.windowEnd) return;
|
||||
try {
|
||||
const { data, includedMap } = await fetchLignes(rangeUrl(this.windowStart, this.windowEnd));
|
||||
this.rows = buildRows(data, includedMap);
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
}
|
||||
},
|
||||
async loadOlder() {
|
||||
// Bypassed while a specific "Année" filter is active -- that mode
|
||||
// loads exactly one year and nothing else (see enterYearMode).
|
||||
if (this.loadingOlder || !this.windowStart || this.filterYear) return;
|
||||
this.loadingOlder = true;
|
||||
try {
|
||||
const newStart = addMonths(this.windowStart, -EXTEND_MONTHS);
|
||||
const { data, includedMap } = await fetchLignes(rangeUrl(newStart, this.windowStart));
|
||||
const newRows = buildRows(data, includedMap);
|
||||
this.windowStart = newStart;
|
||||
|
||||
if (newRows.length) {
|
||||
const wrap = this.$refs.tableWrap;
|
||||
const prevScrollHeight = wrap ? wrap.scrollHeight : 0;
|
||||
this.rows = [...newRows, ...this.rows];
|
||||
await this.$nextTick();
|
||||
// Prepending pushes existing content down -- keep whatever
|
||||
// the user was looking at in the same visual spot.
|
||||
if (wrap) wrap.scrollTop += wrap.scrollHeight - prevScrollHeight;
|
||||
}
|
||||
|
||||
// Trim the far (newer) end once the window's grown past the cap
|
||||
// -- happens below the current scroll position, so no visual
|
||||
// jump to compensate for.
|
||||
const maxEnd = addMonths(this.windowStart, MAX_LOADED_MONTHS);
|
||||
if (this.windowEnd > maxEnd) {
|
||||
this.rows = this.rows.filter((r) => r.date < maxEnd);
|
||||
this.windowEnd = maxEnd;
|
||||
}
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
} finally {
|
||||
this.loadingOlder = false;
|
||||
}
|
||||
},
|
||||
async loadNewer() {
|
||||
if (this.loadingNewer || !this.windowEnd || this.filterYear) return;
|
||||
this.loadingNewer = true;
|
||||
try {
|
||||
const newEnd = addMonths(this.windowEnd, EXTEND_MONTHS);
|
||||
const { data, includedMap } = await fetchLignes(rangeUrl(this.windowEnd, newEnd));
|
||||
const newRows = buildRows(data, includedMap);
|
||||
this.windowEnd = newEnd;
|
||||
|
||||
if (newRows.length) {
|
||||
// Appended below the viewport -- no scroll compensation needed.
|
||||
this.rows = [...this.rows, ...newRows];
|
||||
}
|
||||
|
||||
// Trim the far (older) end once past the cap -- this IS above
|
||||
// the viewport, so compensate scrollTop the same way loadOlder
|
||||
// does for its prepend.
|
||||
const minStart = addMonths(this.windowEnd, -MAX_LOADED_MONTHS);
|
||||
if (this.windowStart < minStart) {
|
||||
const wrap = this.$refs.tableWrap;
|
||||
const prevScrollHeight = wrap ? wrap.scrollHeight : 0;
|
||||
const prevScrollTop = wrap ? wrap.scrollTop : 0;
|
||||
this.rows = this.rows.filter((r) => r.date >= minStart);
|
||||
this.windowStart = minStart;
|
||||
await this.$nextTick();
|
||||
if (wrap) wrap.scrollTop = prevScrollTop - (prevScrollHeight - wrap.scrollHeight);
|
||||
}
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
} finally {
|
||||
this.loadingNewer = false;
|
||||
}
|
||||
},
|
||||
// Scroll-position based rather than IntersectionObserver: one
|
||||
// unified scroll handler drives both edge-loading and
|
||||
// detectCurrentYear() below, instead of maintaining two separate
|
||||
// observer instances for what's fundamentally the same "where is
|
||||
// the user looking right now" question.
|
||||
checkEdges() {
|
||||
const wrap = this.$refs.tableWrap;
|
||||
if (!wrap) return;
|
||||
const threshold = 400;
|
||||
if (wrap.scrollTop < threshold) this.loadOlder();
|
||||
if (wrap.scrollTop + wrap.clientHeight > wrap.scrollHeight - threshold) this.loadNewer();
|
||||
},
|
||||
// Which year is "at the top" of the visible area right now (just
|
||||
// under the sticky thead) -- drives the totals footer. Works
|
||||
// regardless of grouping mode: every rendered row (group header or
|
||||
// data row) carries a data-year attribute.
|
||||
detectCurrentYear() {
|
||||
const wrap = this.$refs.tableWrap;
|
||||
const table = this.$refs.tableEl;
|
||||
if (!wrap || !table) return;
|
||||
const thead = table.querySelector('thead');
|
||||
const wrapRect = wrap.getBoundingClientRect();
|
||||
const thresholdY = wrapRect.top + (thead ? thead.getBoundingClientRect().height : 0) + 2;
|
||||
const rows = table.querySelectorAll('tbody tr[data-year]');
|
||||
let year = null;
|
||||
for (const tr of rows) {
|
||||
const rect = tr.getBoundingClientRect();
|
||||
if (rect.bottom > thresholdY) {
|
||||
year = tr.dataset.year;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (year && year !== this.currentYear) {
|
||||
this.currentYear = year;
|
||||
this.loadCurrentYearTotals();
|
||||
}
|
||||
},
|
||||
onScroll() {
|
||||
if (this._scrollRaf) return;
|
||||
this._scrollRaf = requestAnimationFrame(() => {
|
||||
this._scrollRaf = null;
|
||||
this.detectCurrentYear();
|
||||
this.checkEdges();
|
||||
});
|
||||
},
|
||||
async loadCurrentYearTotals() {
|
||||
if (!this.currentYear) return;
|
||||
this.currentYearLoading = true;
|
||||
try {
|
||||
this.currentYearTotals = await fetchYearTotals(this.currentYear);
|
||||
} catch (err) {
|
||||
this.currentYearTotals = null;
|
||||
} finally {
|
||||
this.currentYearLoading = false;
|
||||
}
|
||||
},
|
||||
// Picking a specific "Année" bypasses the sliding window entirely --
|
||||
// load exactly that year (a bounded, small fetch on its own) rather
|
||||
// than post-filtering whatever happens to be in the current window,
|
||||
// which could easily be empty for a year the user hasn't scrolled
|
||||
// to yet.
|
||||
async enterYearMode(year) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const start = year + '-01-01';
|
||||
const end = (parseInt(year, 10) + 1) + '-01-01';
|
||||
const { data, includedMap } = await fetchLignes(rangeUrl(start, end));
|
||||
this.rows = buildRows(data, includedMap);
|
||||
this.windowStart = start;
|
||||
this.windowEnd = end;
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
await this.$nextTick();
|
||||
if (this.$refs.tableWrap) this.$refs.tableWrap.scrollTop = 0;
|
||||
this.detectCurrentYear();
|
||||
},
|
||||
// Back to "Toutes" -- resume the normal sliding window, re-centered
|
||||
// on today rather than trying to resume exactly where the year
|
||||
// filter was applied from.
|
||||
async exitYearMode() {
|
||||
await this.load();
|
||||
await this.$nextTick();
|
||||
const wrap = this.$refs.tableWrap;
|
||||
if (wrap) wrap.scrollTop = wrap.scrollHeight;
|
||||
this.detectCurrentYear();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.load();
|
||||
jQuery(document).on('dialog:afterclose', () => this.load(false));
|
||||
watch: {
|
||||
filterYear(newYear, oldYear) {
|
||||
if (newYear) {
|
||||
this.enterYearMode(newYear);
|
||||
} else if (oldYear) {
|
||||
this.exitYearMode();
|
||||
}
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
await this.load();
|
||||
// Independent of the row window, so the dropdowns don't shrink to
|
||||
// "whatever happens to be loaded right now".
|
||||
fetchClientNames().then((names) => { this.allClientsList = names; }).catch(() => {});
|
||||
fetchYearsList().then((years) => { this.allYearsList = years; }).catch(() => {});
|
||||
jQuery(document).on('dialog:afterclose', () => this.reloadWindow());
|
||||
await this.$nextTick();
|
||||
const wrap = this.$refs.tableWrap;
|
||||
if (wrap) {
|
||||
wrap.addEventListener('scroll', this.onScroll, { passive: true });
|
||||
// Start scrolled to the most recent data (the window is centered
|
||||
// on "today", so that's the bottom of what's loaded).
|
||||
wrap.scrollTop = wrap.scrollHeight;
|
||||
}
|
||||
this.detectCurrentYear();
|
||||
this.checkEdges();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\figli_compta_ledger\Controller;
|
||||
|
||||
use Drupal\Core\Controller\ControllerBase;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Small aggregate endpoints backing the /lignes sliding window: the
|
||||
* row-level JSON:API fetch only ever covers a date range (see home.js), so
|
||||
* neither the totals footer nor the "Année" dropdown can be computed from
|
||||
* whatever's currently loaded -- they need their own always-accurate
|
||||
* queries, decoupled from the row window.
|
||||
*/
|
||||
class LedgerStatsController extends ControllerBase {
|
||||
|
||||
/**
|
||||
* GET /lignes/api/totaux?annee=2023 -- per-compte répartition sums (plus
|
||||
* montant HT/TTC/écart totals) for every ligne_comptable dated that
|
||||
* year. Ouverture lines are excluded: they're a carried-over balance,
|
||||
* not activity within the year.
|
||||
*/
|
||||
public function totauxAnnee(Request $request) {
|
||||
$annee = $request->query->get('annee');
|
||||
if (!$annee || !preg_match('/^\d{4}$/', $annee)) {
|
||||
return new JsonResponse(['error' => 'Paramètre "annee" invalide.'], 400);
|
||||
}
|
||||
|
||||
$storage = $this->entityTypeManager()->getStorage('node');
|
||||
$nids = $storage->getQuery()
|
||||
->accessCheck(TRUE)
|
||||
->condition('type', 'ligne_comptable')
|
||||
->condition('field_date_ligne', $annee . '-01-01', '>=')
|
||||
->condition('field_date_ligne', ((int) $annee + 1) . '-01-01', '<')
|
||||
->condition('field_type_ligne', 'ouverture', '<>')
|
||||
->execute();
|
||||
|
||||
$par_compte = [];
|
||||
$montant_ht = 0.0;
|
||||
$montant_ttc = 0.0;
|
||||
$ecart = 0.0;
|
||||
foreach ($storage->loadMultiple($nids) as $node) {
|
||||
$ht = $node->hasField('field_montant_ht') && !$node->get('field_montant_ht')->isEmpty()
|
||||
? (float) $node->get('field_montant_ht')->value : 0.0;
|
||||
$ttc = $node->hasField('field_montant_ttc') && !$node->get('field_montant_ttc')->isEmpty()
|
||||
? (float) $node->get('field_montant_ttc')->value : 0.0;
|
||||
$montant_ht += $ht;
|
||||
$montant_ttc += $ttc;
|
||||
|
||||
$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() : NULL;
|
||||
if ($compte) {
|
||||
$par_compte[$compte] = ($par_compte[$compte] ?? 0) + $montant;
|
||||
}
|
||||
$somme += $montant;
|
||||
}
|
||||
$ecart += round($ht - $somme, 2);
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'annee' => $annee,
|
||||
'montant_ht' => round($montant_ht, 2),
|
||||
'montant_ttc' => round($montant_ttc, 2),
|
||||
'ecart' => round($ecart, 2),
|
||||
'par_compte' => array_map(fn ($v) => round($v, 2), $par_compte),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /lignes/api/annees -- distinct years, most recent first, with at
|
||||
* least 5 lines. The threshold exists specifically to keep the handful
|
||||
* of mistyped historical dates (preserved as-is -- e.g. a "0213" typo
|
||||
* for "2023") from polluting the year filter with bogus one-line
|
||||
* "years". A plain SQL aggregate, not Entity API: this only needs the
|
||||
* date column, not full node loads.
|
||||
*/
|
||||
public function annees() {
|
||||
$connection = \Drupal::database();
|
||||
$query = $connection->select('node__field_date_ligne', 'd');
|
||||
$query->addExpression('SUBSTRING(d.field_date_ligne_value, 1, 4)', 'annee');
|
||||
$query->addExpression('COUNT(*)', 'total');
|
||||
$query->condition('d.bundle', 'ligne_comptable');
|
||||
$query->groupBy('annee');
|
||||
$query->having('COUNT(*) >= 5');
|
||||
$results = $query->execute()->fetchCol();
|
||||
|
||||
$annees = array_values($results);
|
||||
rsort($annees);
|
||||
|
||||
return new JsonResponse(['annees' => $annees]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
<label>Client
|
||||
<select v-model="filterClient">
|
||||
<option value="">Tous</option>
|
||||
<option v-for="c in allClients" :key="c" :value="c">{{ c }}</option>
|
||||
<option v-for="c in allClientsList" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<label>Année
|
||||
<select v-model="filterYear">
|
||||
<option value="">Toutes</option>
|
||||
<option v-for="y in allYears" :key="y" :value="y">{{ y }}</option>
|
||||
<option v-for="y in allYearsList" :key="y" :value="y">{{ y }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -57,12 +57,12 @@
|
||||
|
||||
<button type="button" class="button figli-clear-drilldown" v-if="filterEntreeId" @click="filterEntreeId = null">✕ Entrée + sorties liées uniquement</button>
|
||||
|
||||
<span class="figli-count" v-if="!loading">{{ filteredRows.length }} / {{ rows.length }} lignes{{ errorCount ? ' — ' + errorCount + ' avec écart' : '' }}</span>
|
||||
<span class="figli-count" v-if="!loading">{{ filteredRows.length }} / {{ rows.length }} lignes chargées{{ errorCount ? ' — ' + errorCount + ' avec écart' : '' }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="loading">Chargement des données…</p>
|
||||
<p v-else-if="error" class="figli-error">Erreur de chargement : {{ error }}</p>
|
||||
<div v-else class="figli-table-wrap">
|
||||
<div v-else ref="tableWrap" class="figli-table-wrap">
|
||||
<table ref="tableEl" @mouseover="onCellHover" @mouseleave="clearColHover">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -79,11 +79,16 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ref="topSentinel" class="figli-sentinel-row">
|
||||
<td :colspan="6 + allComptes.length + 3">
|
||||
<span v-if="loadingOlder">Chargement des mois précédents…</span>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-for="item in groupedRows" :key="item.key">
|
||||
<tr v-if="item.isGroup" class="figli-group-row">
|
||||
<tr v-if="item.isGroup" class="figli-group-row" :data-year="item.year">
|
||||
<td :colspan="6 + allComptes.length + 3">{{ item.label }} <span class="figli-note">({{ item.count }} lignes)</span></td>
|
||||
</tr>
|
||||
<tr v-else :class="{'figli-error-row': item.hasError}">
|
||||
<tr v-else :class="{'figli-error-row': item.hasError}" :data-year="item.date ? item.date.slice(0, 4) : null">
|
||||
<td class="actions-col">
|
||||
<button
|
||||
v-if="item.linkable"
|
||||
@@ -125,16 +130,24 @@
|
||||
<td class="amount" :class="{'figli-ecart': item.hasError}">{{ item.hasError ? formatEur(item.ecart) : '' }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr ref="bottomSentinel" class="figli-sentinel-row">
|
||||
<td :colspan="6 + allComptes.length + 3">
|
||||
<span v-if="loadingNewer">Chargement des mois suivants…</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="figli-totals-row">
|
||||
<td class="actions-col"></td>
|
||||
<td class="actions-col"></td>
|
||||
<td colspan="4">Solde (créditeur / débiteur) — {{ filteredRows.length }} lignes filtrées</td>
|
||||
<td class="amount">{{ formatEur(footerTotals.montant_ht) }}</td>
|
||||
<td class="amount">{{ formatEur(footerTotals.montant_ttc) }}</td>
|
||||
<td v-for="c in allComptes" :key="c" class="amount compte-col" :class="soldeClass(footerTotals.parCompte[c])">{{ formatEur(footerTotals.parCompte[c]) }}</td>
|
||||
<td class="amount" :class="soldeClass(footerTotals.ecart)">{{ formatEur(footerTotals.ecart) }}</td>
|
||||
<td colspan="4">
|
||||
Solde {{ currentYear || '…' }} (créditeur / débiteur)
|
||||
<span v-if="currentYearLoading" class="figli-note">chargement…</span>
|
||||
</td>
|
||||
<td class="amount">{{ currentYearTotals ? formatEur(currentYearTotals.montant_ht) : '' }}</td>
|
||||
<td class="amount">{{ currentYearTotals ? formatEur(currentYearTotals.montant_ttc) : '' }}</td>
|
||||
<td v-for="c in allComptes" :key="c" class="amount compte-col" :class="currentYearTotals ? soldeClass(currentYearTotals.par_compte[c]) : ''">{{ currentYearTotals && currentYearTotals.par_compte[c] !== undefined ? formatEur(currentYearTotals.par_compte[c]) : '' }}</td>
|
||||
<td class="amount" :class="currentYearTotals ? soldeClass(currentYearTotals.ecart) : ''">{{ currentYearTotals ? formatEur(currentYearTotals.ecart) : '' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user