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.
1366 lines
64 KiB
JavaScript
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);
|