Files
drupal-figli-compta/web/modules/custom/figli_compta_ledger/js/home.js
T
bachir fc89ae6232 Resolve versement reste-à-verser/sur-versé from the server proactively
The badge on a versement freelance row previously only got the correct
status once someone clicked it -- before that it could show "inconnu"
(or, worse, silently default to "ok") whenever its linked entrée wasn't
in the currently loaded sliding window, which is common: the entrée
that got paid can be dated years before or after the versement itself.

Also fixed a latent bug in the click path itself: toggleEntreeFilter()
passed the *entrée's* id to loadEntreeGroup(), which needs an
already-loaded row to find a starting nid for the server-side query --
if that entrée wasn't loaded (the exact case this is all about), the
lookup silently found nothing and did nothing. Split the fetch+merge
logic into fetchGroupFromNid() and let loadEntreeGroup() accept a
fallbackNid (the versement's own, always loaded) to start the
traversal from when the entrée itself isn't available locally --
LedgerStatsController::groupeEntree() finds the same connected
component either way.

New ensureLinkedReconciliationResolved(), triggered by a `rows` watcher
after every load/loadOlder/loadNewer, calls the same server-backed
resolution for every visible linkable row instead of waiting for a
click, deduplicated via resolvedLinkGroups so rows sharing an entrée
don't each trigger their own fetch.

Verified live: the LA MINE versement (own date 2022, linked entrées
dated 2023 and 2025) now shows "⚠ Sur-versé" immediately on page load
with zero clicks, where before it required manually opening the badge
first. Drill-down modal and the "Non liée" -> link-form click path both
still work; no console errors on repeated fresh loads.
2026-09-06 12:21:53 +02:00

1366 lines
64 KiB
JavaScript

/**
* @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.
*
* 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';
const API_BASE = '/jsonapi/node/ligne_comptable';
const EUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' });
const MONTHS = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
const TYPE_LABELS = {
entree: 'Entrée client',
charge: 'Charge structurelle',
versement: 'Versement freelance',
achat: 'Achat client',
hebergement: 'Hébergement',
sous_traitant: 'Sous-traitant',
salaire_stage: 'Salaire / stage',
charges_local_pro: 'Charges local pro',
autre: 'Autre',
ouverture: 'Ouverture',
};
// Sorties that can be linked to the entrée client they pay out against
// (field_entree_liee) -- charge/autre/ouverture/salaire_stage/
// charges_local_pro aren't client-specific.
const LINKABLE_TYPES = ['versement', 'achat', 'hebergement', 'sous_traitant'];
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
// While a thinning filter (compte/client/type/écarts) is active, the cap
// above is actively counter-productive: extending one end and trimming
// the other nets out to roughly the same 30-month slice every round
// (see ensureScrollable()), so a filter with only a handful of matches
// 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.
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..
// MAX_LOADABLE_DATE span at EXTEND_MONTHS per round.
const MAX_ENSURE_SCROLLABLE_ROUNDS = 30;
// Absolute bounds on how far the window can extend. Without these,
// loadOlder()/loadNewer() kept pushing windowStart/windowEnd outward
// even when a fetch came back empty (e.g. extending into future dates
// with no data yet) -- since nothing about the table's height or
// scroll position changes when 0 rows come back, checkEdges() stayed
// satisfied and the next scroll/layout tick fired the exact same load
// again, drifting further out forever with no way to stop. Comfortably
// before the earliest migrated year (2021) and well past any plausible
// future-dated entry.
const MIN_LOADABLE_DATE = '2020-06-01';
const MAX_LOADABLE_DATE = addMonths(todayStr(), 24);
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.
const allData = [];
const includedMap = new Map();
while (url) {
const res = await fetch(url, { headers: { Accept: 'application/vnd.api+json' } });
if (!res.ok) throw new Error('JSON:API a répondu ' + res.status);
const json = await res.json();
allData.push(...(json.data || []));
(json.included || []).forEach((item) => includedMap.set(item.type + ':' + item.id, item));
url = json.links && json.links.next ? json.links.next.href : null;
}
// Defensive de-dup by node id, in case pagination ever repeats a row.
const seen = new Set();
const dedup = allData.filter((n) => (seen.has(n.id) ? false : (seen.add(n.id), true)));
return { data: dedup, includedMap };
}
// GET /lignes/api/groupe/{nid} -- the full transitive closure of node
// ids connected via field_entree_liee (both directions), resolved
// server-side since the sliding window can't discover it client-side
// (see LedgerStatsController::groupeEntree()).
async function fetchGroupeIds(nid) {
const res = await fetch('/lignes/api/groupe/' + nid, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error('/lignes/api/groupe a répondu ' + res.status);
const json = await res.json();
return json.nids || [];
}
// Fetches full ligne data (same includes as the sliding window) for a
// specific set of node ids, regardless of date -- used to fill in
// whichever entrée/sortie nodes fetchGroupeIds() found that aren't in
// the currently loaded window.
async function fetchLignesByNids(nids) {
if (!nids.length) return { data: [], includedMap: new Map() };
const params = new URLSearchParams();
params.set('include', 'field_repartition,field_repartition.field_compte,field_client,field_entree_liee');
params.set('filter[parNid][condition][path]', 'drupal_internal__nid');
params.set('filter[parNid][condition][operator]', 'IN');
nids.forEach((nid) => params.append('filter[parNid][condition][value][]', nid));
return fetchLignes(API_BASE + '?' + params.toString());
}
async function fetchClientNames() {
// page[limit]=200 is silently clamped to core's hard cap of 50 by
// JSON:API (Query\OffsetPage::SIZE_MAX) -- with 106 client terms,
// that cut off everything past the 50th alphabetically (e.g. "LE
// CAMPUS") unless every page is followed, same as fetchLignes() above.
let url = '/jsonapi/taxonomy_term/client?sort=name&page[limit]=50';
const names = [];
while (url) {
const res = await fetch(url, { headers: { Accept: 'application/vnd.api+json' } });
if (!res.ok) throw new Error('JSON:API a répondu ' + res.status);
const json = await res.json();
names.push(...(json.data || []).map((t) => t.attributes.name).filter(Boolean));
url = json.links && json.links.next ? json.links.next.href : null;
}
return names.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 || [];
}
// { "2023": { "Bachir": -0.03, ... }, ... } -- years where the actual
// ouverture doesn't match the previous year's calculated closing
// balance (that year's own ouverture + every movement dated within
// it). Small dataset (a handful of year boundaries), fetched once.
async function fetchOuvertureEcarts() {
const res = await fetch('/lignes/api/reconciliation-ouverture', { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error('/lignes/api/reconciliation-ouverture a répondu ' + res.status);
return res.json();
}
// POST /lignes/{nid}/type -- change field_type_ligne without opening the
// full edit form. Fetches a fresh CSRF token each time (core's
// /session/token) rather than caching one -- this is an occasional
// action, not worth the complexity of handling a stale cached token.
async function updateLigneType(nid, type) {
const tokenRes = await fetch('/session/token');
const token = await tokenRes.text();
const res = await fetch('/lignes/' + nid + '/type', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token },
body: JSON.stringify({ type }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json.error || ('/lignes/' + nid + '/type a répondu ' + res.status));
return json;
}
// POST /lignes/{nid}/champ -- change client/facture/libellé without
// opening the full edit form. Same fresh-token-per-call reasoning as
// updateLigneType() above.
async function updateLigneField(nid, field, value) {
const tokenRes = await fetch('/session/token');
const token = await tokenRes.text();
const res = await fetch('/lignes/' + nid + '/champ', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token },
body: JSON.stringify({ field, value }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json.error || ('/lignes/' + nid + '/champ a répondu ' + res.status));
return json;
}
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();
}
// URL hash (#compte=Maud,Bachir&type=versement,achat&annee=2023&ecarts=1&aller=2024)
// makes the filters and "Aller à" shareable/reloadable -- reload the
// page or send the link and you land back in the same filtered view.
// compte/type can each hold several values (comma-separated) since
// both filters are now <select multiple>.
// "aller" isn't an ongoing filter (jumpYearValue itself always resets
// to '' right after firing) but its target year is worth remembering
// for this purpose, so it's tracked separately (lastJumpYear) purely
// for the hash. Not included: groupBy, filterEntreeId -- out of scope
// of what was asked (the toolbar filters + "Aller à").
function readHashState() {
const params = new URLSearchParams(location.hash.replace(/^#/, ''));
return {
filterCompte: params.get('compte') ? params.get('compte').split(',') : [],
filterClient: params.get('client') || '',
filterType: params.get('type') ? params.get('type').split(',') : [],
filterYear: params.get('annee') || '',
onlyErrors: params.get('ecarts') === '1',
aller: params.get('aller') || '',
};
}
function buildHashString(state) {
const params = new URLSearchParams();
if (state.filterCompte.length) params.set('compte', state.filterCompte.join(','));
if (state.filterClient) params.set('client', state.filterClient);
if (state.filterType.length) params.set('type', state.filterType.join(','));
if (state.filterYear) params.set('annee', state.filterYear);
if (state.onlyErrors) params.set('ecarts', '1');
// Redundant/ambiguous alongside an active "Année" filter -- that
// already fully describes the year, so don't also carry a stale
// "aller" target into the hash.
if (state.lastJumpYear && !state.filterYear) params.set('aller', state.lastJumpYear);
return params.toString();
}
function resolve(includedMap, ref) {
if (!ref) return null;
return includedMap.get(ref.type + ':' + ref.id) || null;
}
function buildRows(data, includedMap) {
const rows = [];
for (const node of data) {
const rels = node.relationships || {};
const attrs = node.attributes;
const clientTerm = resolve(includedMap, rels.field_client && rels.field_client.data);
// field_entree_liee is multi-value (a single payment sometimes
// covers several client invoices at once) -- JSON:API always
// returns an array for a multi-cardinality relationship, one ref
// per linked entrée, even when there's only one or none.
const entreeLieeRefs = (rels.field_entree_liee && rels.field_entree_liee.data) || [];
const entreeLieeNodes = entreeLieeRefs.map((ref) => resolve(includedMap, ref)).filter(Boolean);
const parCompte = {};
let somme = 0;
const repartitionRefs = (rels.field_repartition && rels.field_repartition.data) || [];
for (const ref of repartitionRefs) {
const paragraph = resolve(includedMap, ref);
if (!paragraph) continue;
const montant = parseFloat(paragraph.attributes.field_montant || 0);
const compteTerm = resolve(includedMap, paragraph.relationships && paragraph.relationships.field_compte && paragraph.relationships.field_compte.data);
const compteName = compteTerm ? compteTerm.attributes.name : '(compte inconnu)';
parCompte[compteName] = (parCompte[compteName] || 0) + montant;
somme += montant;
}
const montantHt = attrs.field_montant_ht !== null && attrs.field_montant_ht !== undefined ? parseFloat(attrs.field_montant_ht) : null;
const ecart = montantHt !== null ? Math.round((montantHt - somme) * 100) / 100 : 0;
rows.push({
id: node.id,
nid: attrs.drupal_internal__nid,
date: attrs.field_date_ligne,
type: attrs.field_type_ligne,
client: clientTerm ? clientTerm.attributes.name : null,
facture: attrs.field_numero_facture || null,
libelle: attrs.field_notes || attrs.title,
montant_ht: montantHt,
montant_ttc: attrs.field_montant_ttc !== null && attrs.field_montant_ttc !== undefined ? parseFloat(attrs.field_montant_ttc) : null,
parCompte,
somme,
ecart,
hasError: Math.abs(ecart) > 0.01,
linkable: LINKABLE_TYPES.includes(attrs.field_type_ligne),
entreeLieeIds: entreeLieeNodes.map((n) => n.id),
entreeLieeLabels: entreeLieeNodes.map((n) => n.attributes.title || n.id),
});
}
rows.sort((a, b) => (a.date || '').localeCompare(b.date || ''));
return rows;
}
const App = {
data() {
return {
loading: true,
error: null,
rows: [],
allComptes: ['Sandrine', 'Maud', 'Ouidade', 'Chloé', 'Bachir', 'Valentin', 'EXT.', 'EXT.WEB'],
allClientsList: [],
allYearsList: [],
ouvertureEcarts: {},
// Arrays -- both are native <select multiple>, so ctrl/cmd+click
// and shift+click just work (Vue's v-model binds a <select
// multiple> to an array natively, no custom handling needed). An
// empty array means "tous", same as the single-value '' used to.
filterCompte: [],
filterClient: '',
filterType: [],
filterYear: '',
jumpYearValue: '',
// Not a filter itself (jumpYearValue always resets to '' right
// after firing) -- just remembers the last "Aller à" target so
// the URL hash can reflect it. See syncHash()/readHashState().
lastJumpYear: '',
groupBy: 'month',
onlyErrors: false,
hoverCol: null,
filterEntreeId: null,
// Rows fetched to fill in entrées/sorties that /lignes/api/groupe
// found but the sliding window doesn't have loaded (a versement
// can link to an entrée from any prior year) -- see
// allKnownRows()/toggleEntreeFilter(). Kept around (not cleared
// on close) as a soft cache: reopening the same or an overlapping
// group doesn't need to re-fetch.
groupExtraRows: [],
groupLoading: false,
// Entrée ids whose full linked group has already been resolved
// by ensureLinkedReconciliationResolved() below (or by opening
// the drill-down modal for it) -- avoids re-fetching the same
// group again for every other sortie that happens to link to
// the same entrée.
resolvedLinkGroups: new Set(),
// Which row's type badge is currently showing its inline <select>
// instead of the badge -- only one at a time.
editingTypeId: null,
typeUpdateError: null,
// Which row+field (client/facture/libelle) is currently showing
// its inline <input> instead of the plain text -- only one at a
// time, mirroring editingTypeId above. { id, field } or null.
editingCell: null,
// Fenêtre glissante.
windowStart: null,
windowEnd: null,
loadingOlder: false,
loadingNewer: false,
// True while EITHER loadOlder() or loadNewer() is running -- they
// both mutate `rows`/windowStart/windowEnd, and a scroll-position
// compensation write inside one (from trimming the far end) fires
// a real 'scroll' event that can kick off the other direction
// while the first is still in flight. Without a shared lock, the
// two interleave and race on the same state -- observed as `rows`
// transiently emptying out (table blinks away) before settling.
// loadingOlder/loadingNewer stay separate for the sentinel-row
// text; this gates actual execution.
loadingWindow: 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 }));
},
errorCount() {
return this.rows.filter((r) => r.hasError).length;
},
// Every row known locally that could feed the reconciliation/
// drill-down computeds below: the loaded sliding window, plus
// whatever groupExtraRows filled in (see toggleEntreeFilter()) --
// deduped by id in case the same node ends up in both. A versement
// can link to (or be linked from) an entrée dated years earlier or
// later, well outside `rows`; groupExtraRows is how that gap gets
// closed once the full group has been resolved server-side.
allKnownRows() {
if (!this.groupExtraRows.length) return this.rows;
const seen = new Set(this.rows.map((r) => r.id));
return [...this.rows, ...this.groupExtraRows.filter((r) => !seen.has(r.id))];
},
// A sortie linked to several entrées (one payment covering several
// invoices) appears under each of them here.
sortiesByEntree() {
const map = new Map();
for (const r of this.allKnownRows) {
for (const entreeId of r.entreeLieeIds) {
if (!map.has(entreeId)) map.set(entreeId, []);
map.get(entreeId).push(r);
}
}
return map;
},
// Compares each entrée's répartition (positive shares owed) against
// the combined répartition of every sortie linked to it (negative
// amounts paid out), per compte -- both sides can be split across
// several comptes. A residual near zero means fully settled;
// 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. Only sees whatever sortiesByEntree
// knows about -- accurate for an entrée once its full group has
// been resolved (see allKnownRows()/toggleEntreeFilter()), but may
// under-count a sortie linked to it that's neither in the loaded
// window nor already fetched into groupExtraRows.
//
// A sortie linked to several entrées at once (one payment covering
// several invoices) has no record of how much of it applies to
// each -- there's no per-link amount, just a set of linked entrées.
// Split its répartition equally between them as the least-wrong
// assumption available, rather than counting its full amount
// against every linked entrée (which would double- or triple-count
// the same money).
reconciliationByEntree() {
const map = new Map();
for (const entreeRow of this.allKnownRows) {
if (entreeRow.type !== 'entree') continue;
const linked = this.sortiesByEntree.get(entreeRow.id) || [];
const versementsParCompte = {};
for (const s of linked) {
const share = s.entreeLieeIds.length || 1;
for (const [compte, montant] of Object.entries(s.parCompte)) {
versementsParCompte[compte] = (versementsParCompte[compte] || 0) + montant / share;
}
}
const comptes = new Set([...Object.keys(entreeRow.parCompte), ...Object.keys(versementsParCompte)]);
let resteAVerser = 0;
let surVerse = 0;
const detail = [];
// Kept per-compte (not just folded into the two totals above) --
// linkStatus() below needs to check a single sortie's own
// compte(s) against the entrée, not the entrée's overall
// reconciliation, which can span *other* comptes tied to other
// sorties linked to the same entrée.
const parCompteResidual = {};
for (const c of comptes) {
const residual = Math.round(((entreeRow.parCompte[c] || 0) + (versementsParCompte[c] || 0)) * 100) / 100;
parCompteResidual[c] = residual;
if (residual > 0.01) resteAVerser += residual;
else if (residual < -0.01) surVerse += -residual;
if (Math.abs(residual) > 0.01) detail.push(c + ' : ' + this.formatEur(residual));
}
map.set(entreeRow.id, {
count: linked.length,
resteAVerser: Math.round(resteAVerser * 100) / 100,
surVerse: Math.round(surVerse * 100) / 100,
detail: detail.join(', ') || 'Entièrement soldé',
parCompteResidual,
});
}
return map;
},
// The full connected group of entrées + sorties reachable from
// filterEntreeId by following field_entree_liee links transitively.
// A sortie can now link to several entrées at once (split payment),
// so drilling into one entrée should also surface every *other*
// entrée it shares a sortie with, and that entrée's own sorties in
// turn -- not just the originally-clicked entrée's direct links.
// Runs over allKnownRows (loaded window + groupExtraRows), not just
// `rows` -- toggleEntreeFilter() fetches the complete group up
// front via /lignes/api/groupe/{node}, so by the time this
// re-evaluates, every member should already be present regardless
// of its date.
filterEntreeGroup() {
if (!this.filterEntreeId) return null;
const pool = this.allKnownRows;
const entreeIds = new Set([this.filterEntreeId]);
let grown = true;
while (grown) {
grown = false;
for (const r of pool) {
if (r.type === 'entree' || !r.entreeLieeIds.some((id) => entreeIds.has(id))) continue;
for (const id of r.entreeLieeIds) {
if (!entreeIds.has(id)) {
entreeIds.add(id);
grown = true;
}
}
}
}
const entrees = pool.filter((r) => r.type === 'entree' && entreeIds.has(r.id));
const sorties = pool.filter((r) => r.type !== 'entree' && r.entreeLieeIds.some((id) => entreeIds.has(id)));
return [...entrees, ...sorties];
},
// Which comptes actually carry a value somewhere in the drill-down
// group -- unlike the main table (always all 8, so columns line up
// across every row/year), this modal's group is usually 2-4 rows
// touching only 1-2 comptes, so the other 6+ empty columns are pure
// clutter.
modalComptes() {
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.filterYear && (r.date || '').slice(0, 4) !== this.filterYear) return false;
if (this.onlyErrors && !r.hasError) 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
// answers exactly that instead of the current year's totals -- and
// it can be computed locally (unlike the per-year figures, this
// handful of rows is already fully loaded), no server round-trip
// needed. Same shape as /lignes/api/totaux so the template can
// render either the same way.
drilldownTotals() {
if (!this.filterEntreeId) return null;
let montantHt = 0;
let montantTtc = 0;
let ecart = 0;
const parCompte = {};
for (const r of this.filterEntreeGroup) {
montantHt += r.montant_ht || 0;
montantTtc += r.montant_ttc || 0;
ecart += r.ecart || 0;
for (const [c, v] of Object.entries(r.parCompte)) {
parCompte[c] = (parCompte[c] || 0) + v;
}
}
const round = (v) => Math.round(v * 100) / 100;
return {
montant_ht: round(montantHt),
montant_ttc: round(montantTtc),
ecart: round(ecart),
par_compte: Object.fromEntries(Object.entries(parCompte).map(([c, v]) => [c, round(v)])),
};
},
groupedRows() {
const list = this.filteredRows;
if (this.groupBy === 'none') return list;
const groups = new Map();
for (const r of list) {
const d = r.date ? new Date(r.date + 'T00:00:00') : null;
let key, label;
if (this.groupBy === 'year') {
key = d ? String(d.getFullYear()) : '?';
label = key;
} else {
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, 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, year: g.year });
for (const r of g.rows) out.push(Object.assign({ key: 'r-' + r.id }, r));
}
return out;
},
},
methods: {
formatEur(v) {
return v === null || v === undefined ? '' : EUR.format(v);
},
// Écart between this ouverture row's year and the previous year's
// calculated closing balance -- restricted to whichever compte(s)
// this specific row's répartition actually touches (ouverture lines
// are entered one per compte, so showing every compte's écart on
// every row would just repeat the same list). Null if this row's
// compte(s) reconcile cleanly.
ouvertureEcart(item) {
if (item.type !== 'ouverture' || !item.date) return null;
const annee = item.date.slice(0, 4);
const ecarts = this.ouvertureEcarts[annee];
if (!ecarts) return null;
const relevant = Object.keys(item.parCompte).filter((c) => ecarts[c] !== undefined);
if (!relevant.length) return null;
const detail = relevant.map((compte) => compte + ' : ' + this.formatEur(ecarts[compte])).join(', ');
return { detail, comptes: relevant.length };
},
// Status badge for any linkable sortie row (versement, achat,
// hébergement, sous-traitant -- see LINKABLE_TYPES) -- always
// present for one of those types, specifically so a
// linked-but-settled row still gets a badge to drill down through
// (it used to return null there, silently losing the only way to
// open the linked entrée's filtered view for a row with nothing
// wrong to report). "kind" distinguishes not-linked-at-all, a
// residual against at least one linked entrée, or linked-and-settled:
// - not linked at all
// - linked but its own compte(s) still show a residual against at
// least one linked entrée -- deliberately scoped to just the
// compte(s) this row's own répartition touches
// (reconciliationByEntree's parCompteResidual), not the
// entrée's overall resteAVerser/surVerse, which can be driven
// entirely by a *different* compte tied to some other sortie
// linked to the same entrée and would say nothing about
// whether this row's own répartition is settled. When linked
// to several entrées (see reconciliationByEntree's equal-split
// note), each entrée's residual for these compte(s) counts
// separately -- they're independent invoices, each with its
// own outstanding amount.
// - linked and settled
// - inconnu: at least one linked entrée's reconciliation couldn't
// be resolved (not yet fetched into groupExtraRows -- see
// allKnownRows()/loadEntreeGroup()). Deliberately distinct from
// "ok": defaulting an unresolved entrée to "settled" would show
// a false all-clear for a row that's actually fine, or one
// that owes money, purely because its linked entrée hasn't
// been fetched yet -- opening the badge resolves it and flips
// the status to whatever it actually is.
linkStatus(item) {
if (!LINKABLE_TYPES.includes(item.type)) return null;
if (!item.entreeLieeIds.length) {
return { kind: 'non-liee', detail: 'Aucune entrée client liée.' };
}
let resteAVerser = 0;
let surVerse = 0;
let unresolved = false;
for (const entreeId of item.entreeLieeIds) {
const recon = this.reconciliationByEntree.get(entreeId);
if (!recon) {
unresolved = true;
continue;
}
for (const c of Object.keys(item.parCompte)) {
const residual = recon.parCompteResidual[c] || 0;
if (residual > 0.01) resteAVerser += residual;
else if (residual < -0.01) surVerse += -residual;
}
}
if (resteAVerser > 0.01) {
return { kind: 'reste', detail: 'Reste à verser (comptes de cette ligne) : ' + this.formatEur(Math.round(resteAVerser * 100) / 100) };
}
if (surVerse > 0.01) {
return { kind: 'sur-verse', detail: 'Sur-versé (comptes de cette ligne) : ' + this.formatEur(Math.round(surVerse * 100) / 100) };
}
if (unresolved) {
return { kind: 'inconnu', detail: 'Entrée(s) liée(s) pas encore vérifiée(s) -- cliquer pour vérifier.' };
}
const n = item.entreeLieeIds.length;
return { kind: 'ok', detail: 'Lié à ' + n + ' entrée' + (n > 1 ? 's' : '') + ' client' + (n > 1 ? 's' : '') + '.' };
},
linkStatusLabel(kind) {
if (kind === 'non-liee') return 'Non liée';
if (kind === 'reste') return 'Reste à verser';
if (kind === 'sur-verse') return 'Sur-versé';
if (kind === 'inconnu') return 'À vérifier';
return 'Lié';
},
// Only non-liee/sur-versé read as a hard anomaly (red); reste and
// inconnu are their own softer amber; ok gets neither, falling back
// to the badge's default green -- same "all clear" green the
// entrée side already uses for a fully-settled "N sorties liées".
linkStatusClasses(kind) {
return {
'is-anomalie': kind === 'non-liee' || kind === 'sur-verse',
'is-reste': kind === 'reste' || kind === 'inconnu',
};
},
// jj/mm/aa -- shorter than the API's ISO yyyy-mm-dd, saves column
// width in a table already packed with 8 compte columns.
formatDate(iso) {
if (!iso) return '';
const [y, m, d] = iso.split('-');
return d + '/' + m + '/' + y.slice(2);
},
typeLabel(t) {
return TYPE_LABELS[t] || t;
},
soldeClass(v) {
if (v > 0.5) return 'figli-solde-crediteur';
if (v < -0.5) return 'figli-solde-debiteur';
return '';
},
// Single colored anchor per row (Montant HT only, not every compte
// column) so entrées/sorties are scannable at a glance without the
// table turning into a red/green garland. No 0.5€ threshold like
// soldeClass -- individual lines are often small (e.g. a -1.07€ OVH
// renewal), any nonzero sign should read as positive/negative.
montantClass(v) {
if (v > 0) return 'figli-montant-positif';
if (v < 0) return 'figli-montant-negatif';
return '';
},
openAddForm() {
Drupal.ajax({
url: '/node/add/ligne_comptable',
dialogType: 'modal',
dialog: { width: 800, title: 'Ajouter une ligne comptable' },
progress: { type: 'throbber' },
}).execute();
},
openEditForm(nid) {
Drupal.ajax({
url: '/node/' + nid + '/edit',
dialogType: 'modal',
dialog: { width: 800, title: 'Modifier la ligne comptable' },
progress: { type: 'throbber' },
}).execute();
},
openLinkForm(nid) {
Drupal.ajax({
url: '/lignes/' + nid + '/lier',
dialogType: 'modal',
dialog: { width: 500, title: 'Lier à une entrée client' },
progress: { type: 'throbber' },
}).execute();
},
// Opens/closes the entrée + linked sorties drill-down modal -- a
// separate overlay (see the template), not a filter applied to the
// main table, so opening/closing it never touches the main table's
// scroll position. fallbackNid: see loadEntreeGroup() below --
// clicking a versement's badge passes the versement's own nid,
// since the entrée id alone isn't enough to start the server-side
// lookup when that entrée isn't loaded at all.
toggleEntreeFilter(id, fallbackNid) {
if (this.filterEntreeId === id) {
this.closeDrilldown();
return;
}
this.filterEntreeId = id;
this.loadEntreeGroup(id, fallbackNid);
},
closeDrilldown() {
this.filterEntreeId = null;
},
// Low-level: fetches /lignes/api/groupe/{nid}'s full member list
// (the sliding window can't discover it client-side -- see
// LedgerStatsController::groupeEntree()) and merges whatever isn't
// already known locally into groupExtraRows. filterEntreeGroup/
// reconciliationByEntree re-evaluate automatically once that
// lands, since they read allKnownRows (rows + groupExtraRows).
// Shared by loadEntreeGroup() (click-triggered) and
// ensureLinkedReconciliationResolved() (background) below.
async fetchGroupFromNid(nid) {
const nids = await fetchGroupeIds(nid);
const known = new Set(this.allKnownRows.map((r) => r.nid));
const missingNids = nids.filter((n) => !known.has(n));
if (missingNids.length) {
const { data, includedMap } = await fetchLignesByNids(missingNids);
const newRows = buildRows(data, includedMap);
const existingIds = new Set(this.groupExtraRows.map((r) => r.id));
this.groupExtraRows = [...this.groupExtraRows, ...newRows.filter((r) => !existingIds.has(r.id))];
}
},
// Resolves the full transitive-closure group for the given row id.
// id itself might not be loaded at all -- a versement's linked
// entrée can be dated years before or after it -- so fallbackNid
// lets the caller supply a *different*, guaranteed-loaded node's
// nid (e.g. the versement's own) to start the server-side
// traversal from instead; groupeEntree() finds the same connected
// component either way.
async loadEntreeGroup(id, fallbackNid) {
const row = this.allKnownRows.find((r) => r.id === id);
const startNid = row ? row.nid : fallbackNid;
if (!startNid) return;
this.groupLoading = true;
try {
await this.fetchGroupFromNid(startNid);
} catch (err) {
this.typeUpdateError = err.message;
} finally {
this.groupLoading = false;
}
},
// Proactively resolves the same group for every visible linkable
// row instead of waiting for a click -- a linked entrée can be
// dated years before or after the sortie that pays it out, so the
// reste à verser/sur-versé badge can never be answered correctly
// from whatever the sliding window happens to have loaded.
// Triggered by the `rows` watcher, so it re-runs after every
// load/loadOlder/loadNewer. Marks every entrée id a row links to
// (not just the first) before fetching, so another row sharing
// one of the same entrées doesn't queue a redundant fetch for it.
// Errors are swallowed here (unlike loadEntreeGroup() above) --
// this is unattended background work, not something the user
// explicitly asked for, so a failure just leaves the badge
// "inconnu" for that row instead of surfacing an error banner.
async ensureLinkedReconciliationResolved() {
const toFetch = [];
for (const r of this.rows) {
if (!LINKABLE_TYPES.includes(r.type) || !r.entreeLieeIds.length) continue;
if (r.entreeLieeIds.every((eid) => this.resolvedLinkGroups.has(eid))) continue;
r.entreeLieeIds.forEach((eid) => this.resolvedLinkGroups.add(eid));
toFetch.push(r.nid);
}
await Promise.all(toFetch.map((nid) => this.fetchGroupFromNid(nid).catch(() => {})));
},
startEditType(item) {
this.typeUpdateError = null;
this.editingTypeId = item.id;
},
// Optimistically patches the row in `rows` (not the transient
// `item` from groupedRows -- that object is rebuilt from scratch on
// every computed re-evaluation, so mutating it wouldn't stick) for
// instant feedback, then reconciles with the server in the
// background via reloadWindow() (picks up anything else the save
// touched, e.g. a cleared field_entree_liee).
async saveType(item, event) {
const newType = event.target.value;
this.editingTypeId = null;
if (newType === item.type) return;
try {
const result = await updateLigneType(item.nid, newType);
const row = this.rows.find((r) => r.id === item.id);
if (row) {
row.type = newType;
row.linkable = LINKABLE_TYPES.includes(newType);
if (result.entree_liee_cleared) {
row.entreeLieeIds = [];
row.entreeLieeLabels = [];
}
}
this.reloadWindow();
} catch (err) {
this.typeUpdateError = err.message;
}
},
startEditCell(item, field) {
this.typeUpdateError = null;
this.editingCell = { id: item.id, field };
},
isEditingCell(item, field) {
return !!this.editingCell && this.editingCell.id === item.id && this.editingCell.field === field;
},
// Same optimistic-patch-then-close pattern as saveType() above --
// client/facture/libellé don't affect linkability or
// field_entree_liee, so there's nothing else to reconcile via
// reloadWindow() here.
async saveCell(item, field, event) {
const newValue = event.target.value.trim();
this.editingCell = null;
if (newValue === (item[field] || '')) return;
try {
const result = await updateLigneField(item.nid, field, newValue);
const row = this.rows.find((r) => r.id === item.id);
if (row) row[field] = result.value;
// A client name with no existing match gets created on the fly
// (server-side) rather than rejected -- reflect it in the
// filter dropdown/datalist immediately instead of only after a
// reload picks up the new taxonomy term via fetchClientNames().
if (field === 'client' && result.value && !this.allClientsList.includes(result.value)) {
this.allClientsList = [...this.allClientsList, result.value].sort();
}
} catch (err) {
this.typeUpdateError = err.message;
}
},
onCellHover(evt) {
const cell = evt.target.closest('td, th');
if (!cell) return;
// No crosshair on the technical action columns (link/edit icons) --
// there's nothing to compare across rows there.
if (cell.classList.contains('actions-col')) {
this.clearColHover();
return;
}
// Logical column position, not DOM sibling index: the totals row's
// first cell has colspan="4", which shifts every cell.cellIndex
// after it out of alignment with the body rows.
const index = this.logicalColIndex(cell);
if (index === this.hoverCol) return;
this.setColHover(index);
},
clearColHover() {
this.setColHover(null);
},
logicalColIndex(cell) {
let index = 0;
let sib = cell.previousElementSibling;
while (sib) {
index += sib.colSpan || 1;
sib = sib.previousElementSibling;
}
return index;
},
// Whole-column highlight (header + body + footer) to pair with the
// row hover: plain DOM class toggling rather than a Vue-bound class
// per cell, since the column count/order is fixed markup here, not
// data-driven -- no need to thread an index through every <td>.
setColHover(index) {
const table = this.$refs.tableEl;
if (!table) return;
table.querySelectorAll('.figli-col-hover').forEach((el) => el.classList.remove('figli-col-hover'));
this.hoverCol = index;
if (index === null) return;
table.querySelectorAll('tr').forEach((tr) => {
let pos = 0;
for (const cell of tr.children) {
const span = cell.colSpan || 1;
if (index >= pos && index < pos + span) {
cell.classList.add('figli-col-hover');
break;
}
pos += span;
}
});
},
// showLoading defaults to true for the initial mount, where there's
// nothing on screen yet to preserve. A post-edit refresh passes
// false: flipping `loading` back to true would unmount the whole
// v-else table (the "Chargement…" paragraph takes its place) and
// remount it from scratch once the fetch resolves -- exactly the
// full-table flicker/scroll-reset Vue's keyed diffing exists to
// avoid. Reassigning `rows` in place lets Vue patch just the rows
// that actually changed.
async load(showLoading = true) {
if (showLoading) this.loading = true;
this.error = null;
try {
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;
} finally {
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).
// Also bypassed once the window already reaches MIN_LOADABLE_DATE --
// nothing older to fetch, so skip straight out instead of re-firing
// an empty request on every subsequent scroll/layout tick.
if (this.loadingWindow || !this.windowStart || this.filterYear || this.windowStart <= MIN_LOADABLE_DATE) return;
this.loadingWindow = true;
this.loadingOlder = true;
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);
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, this.isFiltering() ? MAX_LOADED_MONTHS_FILTERED : 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;
this.loadingWindow = false;
}
},
async loadNewer() {
// Same MIN_LOADABLE_DATE reasoning as loadOlder(), mirrored at the
// future end.
if (this.loadingWindow || !this.windowEnd || this.filterYear || this.windowEnd >= MAX_LOADABLE_DATE) return;
this.loadingWindow = true;
this.loadingNewer = true;
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);
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, this.isFiltering() ? -MAX_LOADED_MONTHS_FILTERED : -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;
this.loadingWindow = 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;
// No overflow at all -- typically a filter (compte/client/type/
// écarts) has thinned the visible rows enough that the loaded
// window's worth of matches doesn't fill the viewport. scrollTop
// is stuck at 0 either way, so neither of the two checks below
// means anything on its own; try both directions instead of
// waiting for a scroll that can never happen with nothing to
// scroll.
const notScrollable = wrap.scrollHeight <= wrap.clientHeight;
if (notScrollable || wrap.scrollTop < threshold) this.loadOlder();
if (notScrollable || wrap.scrollTop + wrap.clientHeight > wrap.scrollHeight - threshold) this.loadNewer();
},
// Whether compte/client/type/écarts thin filteredRows down from
// whatever's actually loaded -- 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
// other off nets out to almost the same slice every round, so a
// 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.onlyErrors);
},
// Keeps extending the window (both directions) as long as a filter
// leaves too few matching rows to fill the viewport -- otherwise
// there's never another scroll event to hang further loading off
// of, and the sliding window silently stops responding the moment a
// filter (compte/client/type/écarts) makes the visible page short
// enough to not need a scrollbar.
//
// Bounded by a round count regardless, as a safety net: addMonths()
// uses JS's Date#setMonth(), which isn't invertible for month-end
// dates (e.g. 2025-03-31 minus 1 month lands on 2025-03-03, not
// 2025-02-28, because setMonth(1) overflows February), so even with
// the widened cap above, "no change since last round" isn't a
// guaranteed way to detect "there's genuinely nothing more to find".
async ensureScrollable(round = 0) {
if (this.filterYear || round >= MAX_ENSURE_SCROLLABLE_ROUNDS) return;
const wrap = this.$refs.tableWrap;
if (!wrap) return;
await this.$nextTick();
if (wrap.scrollHeight > wrap.clientHeight) return;
const beforeStart = this.windowStart;
const beforeEnd = this.windowEnd;
await this.loadOlder();
await this.loadNewer();
if (this.windowStart === beforeStart && this.windowEnd === beforeEnd) return;
await this.ensureScrollable(round + 1);
},
// 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();
}
},
// No requestAnimationFrame throttle here anymore -- it used a
// "skip if a frame is already pending" guard reset from *inside*
// the rAF callback, which meant a single rAF callback that never
// fires (observed: a backgrounded/non-visible tab, where browsers
// routinely throttle or fully suspend rAF) permanently wedges the
// guard true, silently dropping every future scroll event forever
// -- exactly the "loads once then stops, can't scroll back" bug
// this replaces. detectCurrentYear()/checkEdges() are cheap (DOM
// reads + early-return guards, no work of their own), so calling
// them on every scroll event costs nothing worth throttling for.
onScroll() {
this.detectCurrentYear();
this.checkEdges();
},
// mounted() only runs once, but `v-if="loading"` swaps out the
// <div ref="tableWrap"> for a fresh DOM node every time loading
// toggles true -> false (enterYearMode/exitYearMode/jumpToYear all
// do this) -- the old node's scroll listener goes with it, silently
// leaving the new one with no listener at all (checkEdges() and
// detectCurrentYear(), and so the footer, stop responding to
// scroll). Call this after every such transition, once the new
// wrap exists; the dataset flag makes it a no-op if the element is
// unchanged.
ensureScrollListener() {
const wrap = this.$refs.tableWrap;
if (wrap && !wrap.dataset.figliScrollBound) {
wrap.dataset.figliScrollBound = '1';
wrap.addEventListener('scroll', this.onScroll, { passive: true });
}
},
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.ensureScrollListener();
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.ensureScrollListener();
this.detectCurrentYear();
},
// "Aller à" -- a one-shot navigation shortcut, distinct from the
// "Année" filter: it re-centers the sliding window on 1 January of
// the chosen year and scrolls there, but (unlike filterYear) doesn't
// restrict the view to just that year -- scrolling up/down still
// extends the window normally, so you can still see the overlap
// with neighbouring years.
onJumpYearChange() {
const year = this.jumpYearValue;
this.jumpYearValue = ''; // reset immediately -- this is an action, not a sticky filter
this.jumpToYear(year);
},
async jumpToYear(year) {
if (!year) return;
// A year filter would otherwise fight with the window this sets
// below -- clear it first, silencing the watcher's own
// exitYearMode() so its (different) window doesn't race this one.
if (this.filterYear) {
this._skipYearWatch = true;
this.filterYear = '';
}
this.lastJumpYear = year;
this.syncHash();
this.loading = true;
this.error = null;
try {
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);
} catch (err) {
this.error = err.message;
} finally {
this.loading = false;
}
await this.$nextTick();
const wrap = this.$refs.tableWrap;
if (wrap) wrap.scrollTop = 0;
this.ensureScrollListener();
this.detectCurrentYear();
this.checkEdges();
},
// Reflects the current filters + "Aller à" target in the URL hash
// (replacing, not pushing, so tweaking a dropdown doesn't spam the
// back button with history entries) -- reload the page or share the
// link and readHashState()/mounted() reproduce the same view.
syncHash() {
const hash = buildHashString({
filterCompte: this.filterCompte,
filterClient: this.filterClient,
filterType: this.filterType,
filterYear: this.filterYear,
onlyErrors: this.onlyErrors,
lastJumpYear: this.lastJumpYear,
});
const url = location.pathname + location.search + (hash ? '#' + hash : '');
history.replaceState(null, '', url);
},
},
watch: {
// Fires on every load()/loadOlder()/loadNewer()/jumpToYear()/
// enterYearMode()/exitYearMode() -- all of them reassign `rows`
// (never mutate it in place), so a shallow watch catches every
// window change without needing to hook each function
// individually. See ensureLinkedReconciliationResolved() itself
// for why this needs to run at all.
rows() {
this.ensureLinkedReconciliationResolved();
},
filterYear(newYear, oldYear) {
if (this._skipYearWatch) {
this._skipYearWatch = false;
return;
}
if (newYear) {
this.enterYearMode(newYear);
} else if (oldYear) {
this.exitYearMode();
}
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().
filterCompte() {
this.ensureScrollable();
this.syncHash();
},
filterClient() {
this.ensureScrollable();
this.syncHash();
},
filterType() {
this.ensureScrollable();
this.syncHash();
},
onlyErrors() {
this.ensureScrollable();
this.syncHash();
},
},
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().
const hashState = readHashState();
this.filterCompte = hashState.filterCompte;
this.filterClient = hashState.filterClient;
this.filterType = hashState.filterType;
this.onlyErrors = hashState.onlyErrors;
if (hashState.filterYear) {
this.filterYear = hashState.filterYear;
} else if (hashState.aller) {
this.lastJumpYear = hashState.aller;
this.jumpToYear(hashState.aller);
} else {
await this.load();
await this.$nextTick();
const wrap = this.$refs.tableWrap;
this.ensureScrollListener();
if (wrap) {
// 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();
this.ensureScrollable();
}
// 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(() => {});
fetchOuvertureEcarts().then((ecarts) => { this.ouvertureEcarts = ecarts; }).catch(() => {});
jQuery(document).on('dialog:afterclose', () => this.reloadWindow());
},
};
Drupal.behaviors.figliComptaHome = {
attach(context) {
const root = context.querySelector ? context.querySelector('#figli-home-app') : null;
if (root && !root.dataset.figliInitialized) {
root.dataset.figliInitialized = '1';
Vue.createApp(App)
// Click-to-edit cells (type/client/facture/libellé) swap a
// span for an <input>/<select> via v-if -- Vue doesn't focus a
// newly created element on its own, so without this the first
// click only opened the field and a second click was needed to
// actually type into it. mounted() fires once per v-if
// true-flip, i.e. exactly when the field appears.
.directive('focus', {
mounted(el) {
el.focus();
if (typeof el.select === 'function') el.select();
},
})
.mount(root);
}
},
};
})(Drupal, Vue, jQuery);