Files
drupal-figli-compta/web/modules/custom/figli_compta_ledger/js/home.js
T
bachir 96bd9502d2 Les totaux du footer de /lignes suivent maintenant les filtres actifs
Remplace /lignes/api/totaux (LedgerStatsController::totauxAnnee(),
toujours non filtré) par une agrégation côté client des lignes renvoyées
par LedgerRowsController::index() (même endpoint que la fenêtre
glissante) avec annee=<année visible> + les filtres actifs. Reste une
requête serveur dédiée sur l'année entière (pas de LIMIT/range sur la
requête Entity), donc le total ne dépend jamais de ce qui est
effectivement chargé dans la fenêtre glissante à cet instant -- vérifié :
207 lignes non filtrées vs 13 avec le filtre "OVH", total du footer
identique au calcul indépendant dans les deux cas.

onFilterChanged() déclenche maintenant systématiquement
loadCurrentYearTotals() (pas seulement quand detectCurrentYear() détecte
un changement d'année) -- sinon changer un filtre en restant sur la même
année laissait le footer afficher l'ancien total non filtré.

totauxAnnee() et sa route sont supprimés (plus aucun appelant après ce
changement, vérifié par recherche).
2026-09-08 15:25:18 +02:00

1869 lines
91 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
* 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, and trimmed from the far
* end once the window exceeds MAX_LOADED_MONTHS so it stays a genuine
* sliding buffer, not an ever-growing list. The "Année" filter and the
* totals footer can't be computed from a partial window, so they're backed
* by their own small server endpoints (LedgerStatsController) instead.
*
* Filtering: every toolbar filter (compte, client, type, signalement,
* écarts, recherche libre) is applied server-side, folded straight into
* whatever request populates the window -- see LedgerRowsController and
* fetchFilteredLignes() below. `rows` therefore only ever holds rows that
* both fall in the loaded date range AND match the active filters; there's
* no separate "load everything, filter client-side" pass. JSON:API is
* still used for a few things the filtered endpoint doesn't cover: the
* entrée/sortie group drill-down (fetchLignesByNids()) and live-update
* polling (fetchChangedSince()), both keyed on specific node ids or
* "anything that changed" rather than a date range + filter set.
*/
(function (Drupal, Vue, jQuery) {
'use strict';
const API_BASE = '/jsonapi/node/ligne_comptable';
const EUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' });
// Not style: 'percent' -- that expects a fraction (0.10) and field_tva
// stores a plain rate (10), which style: 'percent' would otherwise
// silently read as 1000%.
const PCT = new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
// Mirrors the $taux_officiels select in figli_compta_ledger_form_alter()
// and the $official list in figli_compta_ledger_update_8007()/_8008() --
// same 0.05-point tolerance as those migration scripts, for the same
// "rounding through 2-decimal HT/TTC storage" reason.
const OFFICIAL_TVA_RATES = [0, 2.1, 5.5, 10, 20];
const OFFICIAL_TVA_EPSILON = 0.05;
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).
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;
// How often to poll for other users' changes (see pollForChanges()) --
// a plain setInterval rather than anything push-based (no Socket.io/
// websocket infra for a 6-person internal tool); this is far more
// frequent than table content actually changes, but the requests are
// cheap and it keeps everyone's view close to live.
const POLL_INTERVAL_MS = 8000;
// 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 query string for LedgerRowsController::index() (GET
// /lignes/api/lignes) -- the server-side-filtered replacement for
// "fetch a JSON:API date range, then filter client-side": every
// toolbar filter is folded straight into the same request that loads
// the window, so `rows` only ever holds what's actually meant to be
// visible right now, pre-shaped exactly like buildRows() below would
// have produced from JSON:API (no separate row-building step needed
// for this path). rangeOrAnnee is either {start, end} (the normal
// sliding window, and jumpToYear()) or {annee} (filterYear's
// enterYearMode) -- the controller accepts either.
function filteredLignesUrl(rangeOrAnnee, filters) {
const params = new URLSearchParams();
if (rangeOrAnnee.annee) {
params.set('annee', rangeOrAnnee.annee);
} else {
params.set('start', rangeOrAnnee.start);
params.set('end', rangeOrAnnee.end);
}
if (filters.compte.length) params.set('compte', filters.compte.join(','));
if (filters.client) params.set('client', filters.client);
if (filters.type.length) params.set('type', filters.type.join(','));
if (filters.flag.length) params.set('flag', filters.flag.join(','));
if (filters.q) params.set('q', filters.q);
if (filters.ecarts) params.set('ecarts', '1');
if (filters.signale) params.set('signale', '1');
return '/lignes/api/lignes?' + params.toString();
}
async function fetchFilteredLignes(rangeOrAnnee, filters) {
const res = await fetch(filteredLignesUrl(rangeOrAnnee, filters), { headers: { Accept: 'application/json' } });
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json.error || ('/lignes/api/lignes a répondu ' + res.status));
return json.rows;
}
async function fetchLignes(url) {
// 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,field_flag');
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());
}
// Any ligne_comptable changed after `tsSeconds` (a Unix timestamp) --
// backs the polling in pollForChanges() below, so one user's edit
// shows up in everyone else's table without a manual reload. `changed`
// is stored as a Unix timestamp internally; JSON:API exposes it as an
// ISO 8601 string in *responses* but only accepts the raw timestamp as
// a *filter value* -- confirmed live (an ISO string filter value
// silently matched everything instead of narrowing the query).
async function fetchChangedSince(tsSeconds) {
const params = new URLSearchParams();
params.set('include', 'field_repartition,field_repartition.field_compte,field_client,field_entree_liee,field_flag');
params.set('filter[changedFilter][condition][path]', 'changed');
params.set('filter[changedFilter][condition][operator]', '>');
params.set('filter[changedFilter][condition][value]', String(tsSeconds));
params.set('sort', 'changed');
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();
}
// Same pagination reasoning as fetchClientNames() above -- feeds the
// "Signalement" column's datalist (suggestions only, doesn't restrict
// input: see LedgerActionsController::updateField()'s 'flag' case).
async function fetchFlagNames() {
let url = '/jsonapi/taxonomy_term/flag?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.
// `changed` (the row's last-known changed timestamp) lets the server
// reject a save-over-a-stale-view instead of silently overwriting
// someone else's concurrent edit -- see LedgerActionsController::
// checkConflict(). The thrown error's `.status` lets callers tell a
// 409 conflict apart from any other failure.
async function updateLigneType(nid, type, changed) {
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, changed }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(json.error || ('/lignes/' + nid + '/type a répondu ' + res.status));
err.status = res.status;
throw err;
}
return json;
}
// POST /lignes/{nid}/champ -- change client/facture/libellé without
// opening the full edit form. Same fresh-token-per-call and conflict-
// detection reasoning as updateLigneType() above.
async function updateLigneField(nid, field, value, changed) {
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, changed }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(json.error || ('/lignes/' + nid + '/champ a répondu ' + res.status));
err.status = res.status;
throw err;
}
return json;
}
// Sums the same fields LedgerRowsController::index() rows carry into
// the {montant_ht, cotisation, montant_ttc, ecart, par_compte} shape
// the footer template expects.
function aggregateTotals(rows) {
let montantHt = 0;
let cotisation = 0;
let montantTtc = 0;
let ecart = 0;
const parCompte = {};
for (const r of rows) {
montantHt += r.montant_ht || 0;
cotisation += r.cotisation || 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),
cotisation: round(cotisation),
montant_ttc: round(montantTtc),
ecart: round(ecart),
par_compte: Object.fromEntries(Object.entries(parCompte).map(([c, v]) => [c, round(v)])),
};
}
// Per-année footer totals, filtered the same way the table itself is --
// fetches every row for the year via the same server-side-filtered
// endpoint the table window uses (fetchFilteredLignes), then sums them
// client-side. Replaces the old /lignes/api/totaux (LedgerStatsController::
// totauxAnnee(), always unfiltered) now that the footer needs to
// reflect whatever's actually on screen, not the whole year regardless
// of the active filters.
async function fetchYearTotals(annee, filters) {
const rows = await fetchFilteredLignes({ annee }, filters);
return Object.assign({ annee }, aggregateTotals(rows));
}
// 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(',') : [],
filterFlag: params.get('tag') ? params.get('tag').split(',') : [],
filterYear: params.get('annee') || '',
filterQ: params.get('q') || '',
onlyErrors: params.get('ecarts') === '1',
onlyFlagged: params.get('signale') === '1',
aller: params.get('aller') || '',
};
}
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.filterFlag.length) params.set('tag', state.filterFlag.join(','));
if (state.filterYear) params.set('annee', state.filterYear);
if (state.filterQ) params.set('q', state.filterQ);
if (state.onlyErrors) params.set('ecarts', '1');
if (state.onlyFlagged) params.set('signale', '1');
// Redundant/ambiguous alongside an active "Année" filter -- that
// 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);
// Multi-value like field_entree_liee above -- zero, one or several
// free-form "signalement" tags (see LedgerActionsController::
// updateField()'s 'flag' case for how they're written).
const flagRefs = (rels.field_flag && rels.field_flag.data) || [];
const flagTerms = flagRefs.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,
// ISO 8601 string (ok as an opaque token here, only ever
// compared for equality/passed straight back to the server) --
// sent back on the next inline edit so the server can detect
// "someone else saved this line in between" and reject instead
// of silently overwriting. See LedgerActionsController::
// checkConflict() and pollForChanges()/mergeChangedRows() below.
changed: attrs.changed,
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,
cotisation: attrs.field_cotisation_urssaf !== null && attrs.field_cotisation_urssaf !== undefined ? parseFloat(attrs.field_cotisation_urssaf) : null,
tva: attrs.field_tva !== null && attrs.field_tva !== undefined ? parseFloat(attrs.field_tva) : null,
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),
flags: flagTerms.map((t) => t.attributes.name),
hasFlag: flagTerms.length > 0,
});
}
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: [],
allFlagsList: [],
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: [],
// Same array/OR-semantics/multiselect model as filterCompte/
// filterType above -- independent of onlyFlagged (the plain on/
// off toggle): this narrows to specific tags, onlyFlagged just
// means "has any tag at all".
filterFlag: [],
filterYear: '',
// Free-text match against Libellé/Détail (field_notes, falling
// back to the title) -- server-side (see LedgerRowsController),
// debounced client-side (see the filterQ watcher) so every
// keystroke doesn't fire its own request.
filterQ: '',
jumpYearValue: '',
// Not a filter itself (jumpYearValue always resets to '' right
// after firing) -- just remembers the last "Aller à" target so
// the URL hash can reflect it. See syncHash()/readHashState().
lastJumpYear: '',
groupBy: 'month',
onlyErrors: false,
onlyFlagged: 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(),
// Unix timestamp (seconds) -- everything with `changed` after
// this has appeared since the last poll. See pollForChanges().
lastPollTs: null,
// 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,
// Row currently open in the signalement modal (see
// openFlagModal()) -- a small standalone object, not the row
// itself, and the text of a not-yet-added tag typed into that
// modal's own input.
flagModalItem: null,
flagModalNewTag: '',
// Fenêtre glissante.
windowStart: null,
windowEnd: null,
// Drive the sentinel-row "Chargement…" text only -- actual
// serialization is the `_windowOpChain` queue in
// _queueWindowOp(), not these (see loadOlder()/loadNewer()).
loadingOlder: false,
loadingNewer: false,
// Footer : totaux de l'année actuellement visible à l'écran (pas
// de la fenêtre chargée), voir detectCurrentYear().
currentYear: null,
currentYearTotals: null,
currentYearLoading: false,
};
},
computed: {
allTypes() {
return Object.entries(TYPE_LABELS).map(([value, label]) => ({ value, label }));
},
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));
},
// 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)])),
};
},
// `rows` no longer needs a separate client-side filtering pass --
// every toolbar filter is already applied server-side by whatever
// fetched the current window (see fetchFilteredLignes()), so this
// only handles the month/year grouping on top.
groupedRows() {
const list = this.rows;
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);
},
// Historical lines can carry a backfilled, non-round rate (e.g.
// 12.6414 -- see figli_compta_ledger_update_8005()'s docblock for
// why); rounded to 2 decimals for display, same as every amount
// column here, rather than showing the full stored precision.
formatPct(v) {
return v === null || v === undefined ? '' : PCT.format(v) + ' %';
},
// Flags a TVA rate that isn't one of the 5 official French VAT
// rates -- either a genuinely blended/multi-rate invoice collapsed
// into one ligne_comptable, or a migrated line
// figli_compta_ledger_update_8007() left ambiguous rather than
// guess (see that function's docblock). Null (no TVA recorded at
// all) is never flagged -- nothing to question there.
isTvaOfficial(v) {
if (v === null || v === undefined) return true;
return OFFICIAL_TVA_RATES.some((o) => Math.abs(v - o) < OFFICIAL_TVA_EPSILON);
},
// É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) {
const montant = Math.round(resteAVerser * 100) / 100;
return { kind: 'reste', montant, detail: 'Reste à verser (comptes de cette ligne) : ' + this.formatEur(montant) };
}
if (surVerse > 0.01) {
const montant = Math.round(surVerse * 100) / 100;
return { kind: 'sur-verse', montant, detail: 'Sur-versé (comptes de cette ligne) : ' + this.formatEur(montant) };
}
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', montant: 0, detail: 'Lié à ' + n + ' entrée' + (n > 1 ? 's' : '') + ' client' + (n > 1 ? 's' : '') + '.' };
},
// Takes the whole status object (not just kind) -- reste/sur-verse/
// ok show the exact solde right in the badge, not just in the
// hover title, since a bare "Reste à verser" with no figure meant
// opening the drill-down modal just to see how much.
linkStatusLabel(status) {
if (status.kind === 'non-liee') return 'Non liée';
if (status.kind === 'reste') return 'Reste à verser : ' + this.formatEur(status.montant);
if (status.kind === 'sur-verse') return 'Sur-versé : ' + this.formatEur(status.montant);
if (status.kind === 'inconnu') return 'À vérifier';
return 'Lié : ' + this.formatEur(status.montant);
},
// 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',
};
},
// aa/mm/jj -- shorter than the API's ISO yyyy-mm-dd (saves column
// width in a table already packed with 8 compte columns) while
// keeping the same year-month-day ordering, so it still sorts
// correctly as plain text and reads unambiguously either way.
formatDate(iso) {
if (!iso) return '';
const [y, m, d] = iso.split('-');
return y.slice(2) + '/' + m + '/' + d;
},
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(() => {})));
},
// Live multi-user updates without websocket/Socket.io infra --
// plain polling (see POLL_INTERVAL_MS) is simple, fits the
// existing fetch-based architecture, and is plenty for a
// 6-person internal tool. A failed poll is silent and just tried
// again next tick (matches other background-refresh code here);
// lastPollTs is deliberately *not* advanced on failure, so a
// transient network blip doesn't miss whatever changed during it.
startPolling() {
setInterval(() => this.pollForChanges(), POLL_INTERVAL_MS);
},
async pollForChanges() {
// setInterval doesn't wait for a previous callback's promise to
// settle before scheduling the next tick -- without this guard,
// a poll that takes longer than POLL_INTERVAL_MS to respond
// (slow network, tab was backgrounded and just resumed) would
// overlap with the next one, both merging into `rows` at once.
if (this._pollInFlight) return;
this._pollInFlight = true;
try {
const { data, includedMap } = await fetchChangedSince(this.lastPollTs);
if (data.length) {
this.mergeChangedRows(buildRows(data, includedMap));
}
this.lastPollTs = Math.floor(Date.now() / 1000);
} catch (err) {
// silent -- see comment above.
} finally {
this._pollInFlight = false;
}
},
// Patches already-loaded rows in place (mutates each row's own
// properties via Object.assign, not the `rows`/`groupExtraRows`
// arrays themselves) so Vue's reactivity updates just the
// affected cells, without re-triggering the `rows` watcher (and
// so ensureLinkedReconciliationResolved()) for routine polls that
// only touch content already known locally. A row currently being
// edited is left alone entirely -- overwriting its value out from
// under an in-progress keystroke would be worse than a few
// seconds of staleness; it'll pick up on the next poll once
// editing finishes. Genuinely new lines (not loaded at all yet)
// that fall inside the current window get appended -- that's the
// one case that *does* need the array reassigned, since there's
// no existing row object to mutate.
//
// `rows` is server-filtered (see fetchFilteredLignes()), unlike
// before Phase 2 where it held the whole window and a computed
// property filtered it for display -- pollForChanges() is
// deliberately still unfiltered/global (see its own comment), so a
// change reported here might have moved a row *out* of the active
// filters (patch would leave a stale non-matching row visible) or
// *into* them (a row not previously loaded now qualifies). A row
// living in groupExtraRows instead is unaffected by the toolbar
// filters at all (that's the drill-down modal's own concern), so
// it's always just patched in place.
mergeChangedRows(changedRows) {
const isBeingEdited = (id) => this.editingTypeId === id || (this.editingCell && this.editingCell.id === id);
const rowsById = new Map(this.rows.map((r) => [r.id, r]));
const extraById = new Map(this.groupExtraRows.map((r) => [r.id, r]));
const newOnes = [];
const removeIds = new Set();
for (const fresh of changedRows) {
if (isBeingEdited(fresh.id)) continue;
const inExtra = extraById.get(fresh.id);
if (inExtra) {
Object.assign(inExtra, fresh);
continue;
}
const inWindow = fresh.date >= this.windowStart && fresh.date < this.windowEnd;
const matches = inWindow && this.rowMatchesFilters(fresh);
const existing = rowsById.get(fresh.id);
if (existing) {
if (matches) {
Object.assign(existing, fresh);
} else {
removeIds.add(fresh.id);
}
} else if (matches) {
newOnes.push(fresh);
}
}
if (newOnes.length || removeIds.size) {
this.rows = [...this.rows.filter((r) => !removeIds.has(r.id)), ...newOnes].sort((a, b) => (a.date || '').localeCompare(b.date || ''));
}
},
startEditType(item) {
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, item.changed);
const row = this.rows.find((r) => r.id === item.id);
if (row) {
row.type = newType;
row.linkable = LINKABLE_TYPES.includes(newType);
row.changed = result.changed;
if (result.entree_liee_cleared) {
row.entreeLieeIds = [];
row.entreeLieeLabels = [];
}
}
this.reloadWindow();
} catch (err) {
this.typeUpdateError = err.message;
if (err.status === 409) this.refreshSingleRow(item.id, item.nid);
}
},
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. Signalement (field_flag) isn't handled here
// -- it's multi-value and edited through its own modal, see
// openFlagModal()/saveFlagList() below.
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, item.changed);
const row = this.rows.find((r) => r.id === item.id);
if (row) {
row[field] = result.value;
row.changed = result.changed;
}
// 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;
if (err.status === 409) this.refreshSingleRow(item.id, item.nid);
}
},
// Signalement modal: one tag per line (add/remove), not a single
// comma-separated text field -- easier to see what's already there
// and remove just one without retyping the rest. flagModalItem is
// its own small reactive object (not aliased to the row in `rows`)
// so the modal keeps working even if the underlying row gets
// trimmed out of the sliding window while it's open; saveFlagList()
// updates both explicitly.
openFlagModal(item) {
this.typeUpdateError = null;
this.flagModalItem = { id: item.id, nid: item.nid, date: item.date, client: item.client, changed: item.changed, flags: item.flags.slice() };
this.flagModalNewTag = '';
},
closeFlagModal() {
this.flagModalItem = null;
this.flagModalNewTag = '';
},
async addFlagTag() {
const name = this.flagModalNewTag.trim();
if (!name || this.flagModalItem.flags.includes(name)) {
this.flagModalNewTag = '';
return;
}
this.flagModalNewTag = '';
await this.saveFlagList([...this.flagModalItem.flags, name]);
},
async removeFlagTag(name) {
await this.saveFlagList(this.flagModalItem.flags.filter((f) => f !== name));
},
async saveFlagList(newFlags) {
const item = this.flagModalItem;
try {
const result = await updateLigneField(item.nid, 'flag', newFlags.join(', '), item.changed);
item.flags = result.value;
item.changed = result.changed;
const row = this.rows.find((r) => r.id === item.id) || this.groupExtraRows.find((r) => r.id === item.id);
if (row) {
row.flags = result.value;
row.hasFlag = result.value.length > 0;
row.changed = result.changed;
}
const newNames = result.value.filter((name) => !this.allFlagsList.includes(name));
if (newNames.length) this.allFlagsList = [...this.allFlagsList, ...newNames].sort();
} catch (err) {
this.typeUpdateError = err.message;
if (err.status === 409) {
this.refreshSingleRow(item.id, item.nid);
this.closeFlagModal();
}
}
},
// 409 conflict from saveType()/saveCell() above -- someone else
// saved this exact line in between. Re-fetch just this one node
// and patch it in place, so the row reflects their change right
// away instead of staying stuck on the stale view that caused the
// rejection (the user would otherwise hit the same conflict again
// on retry without understanding why).
async refreshSingleRow(id, nid) {
try {
const { data, includedMap } = await fetchLignesByNids([nid]);
if (!data.length) return;
const [freshRow] = buildRows(data, includedMap);
const existing = this.rows.find((r) => r.id === id) || this.groupExtraRows.find((r) => r.id === id);
if (existing) Object.assign(existing, freshRow);
} catch (err) {
// Best effort -- the error banner from the conflict itself
// already told the user to reload if this doesn't work out.
}
},
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);
this.rows = await fetchFilteredLignes({ start: this.windowStart, end: this.windowEnd }, this.currentFilters());
} 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. Also
// the reload path every toolbar filter change routes through (see
// onFilterChanged()) -- with filtering now server-side, changing a
// filter has to re-fetch, not just re-scan what's already loaded.
async reloadWindow() {
if (!this.windowStart || !this.windowEnd) return;
try {
this.rows = await fetchFilteredLignes({ start: this.windowStart, end: this.windowEnd }, this.currentFilters());
} catch (err) {
this.error = err.message;
}
},
// Public entry points queue onto a shared chain (see _queueWindowOp)
// instead of running immediately -- see that method for why a
// "drop if busy" guard used to sit here and why it had to go.
loadOlder() {
return this._queueWindowOp(() => this._loadOlderNow());
},
loadNewer() {
return this._queueWindowOp(() => this._loadNewerNow());
},
// Both loadOlder()/loadNewer() 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 --
// interleaving them races on that shared state (observed earlier
// this session as `rows` transiently emptying out). That used to be
// guarded by a `loadingWindow` boolean that made a call arriving
// while another was in flight a silent no-op -- but a no-op is
// indistinguishable from "there's nothing more to load", which is
// exactly the signal ensureScrollable() uses to stop recursing. In
// practice that meant several filters restored from the URL hash at
// once (mounted() sets filterCompte/filterClient/filterType as
// three separate reactive writes, each firing its own watcher) could
// each kick off loadOlder()/loadNewer(), have all but one silently
// dropped by the guard, and have ensureScrollable() conclude "window
// didn't change" and give up after a single round -- even though the
// table was nowhere near scrollable yet, and with nothing left to
// ever retry (no scrollbar, no more scroll events). Queuing instead
// of dropping means every call still eventually runs, in order, once
// whatever's ahead of it in the queue finishes.
_queueWindowOp(fn) {
const run = () => fn().catch((err) => { this.error = err.message; });
this._windowOpChain = (this._windowOpChain || Promise.resolve()).then(run, run);
return this._windowOpChain;
},
async _loadOlderNow() {
// 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. Checked
// here (at actual run time, not call time) since by the time this
// reaches the front of the queue, windowStart may have moved.
if (!this.windowStart || this.filterYear || this.windowStart <= MIN_LOADABLE_DATE) return;
this.loadingOlder = true;
try {
let newStart = addMonths(this.windowStart, -EXTEND_MONTHS);
if (newStart < MIN_LOADABLE_DATE) newStart = MIN_LOADABLE_DATE;
const newRows = await fetchFilteredLignes({ start: newStart, end: this.windowStart }, this.currentFilters());
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. This
// write fires its own native 'scroll' event, re-entering
// checkEdges() -- harmless: loadingOlder is still true at
// this point (reset only in the `finally` below), so that
// event finds the guard there already blocking a redundant
// call, no separate suppression needed.
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;
}
},
async _loadNewerNow() {
// Same MIN_LOADABLE_DATE reasoning as _loadOlderNow(), mirrored at
// the future end.
if (!this.windowEnd || this.filterYear || this.windowEnd >= MAX_LOADABLE_DATE) return;
this.loadingNewer = true;
try {
let newEnd = addMonths(this.windowEnd, EXTEND_MONTHS);
if (newEnd > MAX_LOADABLE_DATE) newEnd = MAX_LOADABLE_DATE;
const newRows = await fetchFilteredLignes({ start: this.windowEnd, end: newEnd }, this.currentFilters());
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;
}
},
// 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;
// Guarded by loadingOlder/loadingNewer, not just called
// unconditionally: loadOlder()/loadNewer() queue instead of
// dropping a call that arrives while one's already running (see
// _queueWindowOp) -- correct for the *distinct* triggers that fix
// was about (several filters changing in the same tick), but a
// single continuous scroll gesture fires many native 'scroll'
// events, and checkEdges() runs on every one of them. Without
// this guard, every one of those events near an edge queued its
// own loadOlder()/loadNewer() call, and every queued call did a
// real fetch + scroll compensation regardless of whether the
// very first one already moved the window away from the edge --
// that pileup is what looked like the same request firing over
// and over, dragging the scroll position around unpredictably.
if ((notScrollable || wrap.scrollTop < threshold) && !this.loadingOlder) this.loadOlder();
if ((notScrollable || wrap.scrollTop + wrap.clientHeight > wrap.scrollHeight - threshold) && !this.loadingNewer) this.loadNewer();
},
// Whether an active filter (server-side now, see
// fetchFilteredLignes()) thins `rows` down to fewer matches than an
// unfiltered window would have -- loadOlder()/loadNewer() use this to
// widen the trim cap (MAX_LOADED_MONTHS_FILTERED instead of
// MAX_LOADED_MONTHS), since the normal cap actively works against a
// sparse filter: extending one end and immediately trimming the
// 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.filterFlag.length || this.filterQ || this.onlyErrors || this.onlyFlagged);
},
// The non-date part of every toolbar filter, in the shape
// fetchFilteredLignes()/filteredLignesUrl() expect -- built fresh
// from current state each call rather than kept as its own piece
// of reactive state, so there's only one source of truth for what
// "the active filters" are.
currentFilters() {
return {
compte: this.filterCompte,
client: this.filterClient,
type: this.filterType,
flag: this.filterFlag,
q: this.filterQ,
ecarts: this.onlyErrors,
signale: this.onlyFlagged,
};
},
// Client-side mirror of the non-date conditions LedgerRowsController
// applies server-side -- used only by mergeChangedRows() below, to
// decide whether a row a poll just reported on still belongs in
// `rows` (which, unlike before Phase 2, only ever holds matches for
// the active filters, not the whole window). filterYear/the date
// window itself is checked separately by the caller, since
// windowStart/windowEnd already cover both the sliding-window and
// year-mode cases identically.
rowMatchesFilters(row) {
if (this.filterCompte.length && !this.filterCompte.some((c) => row.parCompte[c] !== undefined)) return false;
if (this.filterClient && row.client !== this.filterClient) return false;
if (this.filterType.length && !this.filterType.includes(row.type)) return false;
if (this.filterFlag.length && !this.filterFlag.some((f) => row.flags.includes(f))) return false;
if (this.filterQ && !(row.libelle || '').toLowerCase().includes(this.filterQ.toLowerCase())) return false;
if (this.onlyErrors && !row.hasError) return false;
if (this.onlyFlagged && !row.hasFlag) return false;
return true;
},
// Every toolbar filter change routes through here: with filtering
// now server-side (see fetchFilteredLignes()), there's no more
// "just recompute a client-side view" -- the currently loaded
// window has to be re-fetched with the new filter applied. The
// reload itself is queued through the same chain as loadOlder()/
// loadNewer() (see _queueWindowOp) since several filters can change
// in the same tick (e.g. mounted() restoring them all from the URL
// hash at once), and interleaving their fetches would race on
// `rows` the same way parallel loadOlder()/loadNewer() calls used
// to. ensureScrollable() is deliberately chained AFTER that queued
// op settles, not passed into it: ensureScrollable() itself calls
// loadOlder()/loadNewer(), which each enqueue their own op onto the
// very same chain -- queuing it *inside* the op currently occupying
// that chain made the chain await its own continuation (the queued
// op can't finish until its child call, appended behind it on the
// same chain, finishes first) and deadlocked solid the moment a
// filter actually left too few rows to fill the viewport, wedging
// every future filter change and scroll-triggered load right along
// with it.
//
// loadCurrentYearTotals() is called unconditionally here (not just
// left to detectCurrentYear()'s own trigger): that one only
// re-fetches when the *visible year* changes, so a filter change
// while staying on the same year would otherwise leave the footer
// showing stale, pre-filter totals. Independent of the window
// reload above (different endpoint call, no shared state), so no
// need to chain it through the same queue.
onFilterChanged() {
this._queueWindowOp(() => this.reloadWindow()).then(() => this.ensureScrollable());
this.loadCurrentYearTotals();
this.syncHash();
},
// 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".
//
// Public entry point is serialized: mounted() restoring several
// filters from the URL hash (filterCompte/filterClient/filterType)
// assigns each as its own reactive property write, so each fires
// its own watcher and its own independent ensureScrollable(0) call
// in the very same tick. Without serializing, those chains race on
// loadOlder()/loadNewer()'s shared `loadingWindow` guard -- a call
// that lands while another is already in flight just no-ops
// immediately, so a chain can see "window didn't change" and give
// up after one round even though the table is nowhere near
// scrollable yet, and nothing is left afterward to ever retry
// (there's no scrollbar to generate the scroll events checkEdges()
// would otherwise rely on). Queuing instead of racing means the
// first call runs its full recursive loop uninterrupted, and any
// calls that arrived while it was busy collapse into a single
// follow-up run once it's done (covering the final filter state,
// cheap/no-op if that first run already converged).
ensureScrollable() {
if (this._ensureScrollableRunning) {
this._ensureScrollablePending = true;
return this._ensureScrollableRunning;
}
this._ensureScrollableRunning = this._ensureScrollableLoop(0).finally(() => {
this._ensureScrollableRunning = null;
if (this._ensureScrollablePending) {
this._ensureScrollablePending = false;
this.ensureScrollable();
}
});
return this._ensureScrollableRunning;
},
async _ensureScrollableLoop(round) {
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._ensureScrollableLoop(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, this.currentFilters());
} 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';
this.rows = await fetchFilteredLignes({ annee: year }, this.currentFilters());
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);
this.rows = await fetchFilteredLignes({ start: this.windowStart, end: this.windowEnd }, this.currentFilters());
} 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,
filterFlag: this.filterFlag,
filterYear: this.filterYear,
filterQ: this.filterQ,
onlyErrors: this.onlyErrors,
onlyFlagged: this.onlyFlagged,
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 is now pushed server-side (see LedgerRowsController/
// fetchFilteredLignes()) -- onFilterChanged() re-fetches the
// currently loaded window with the new filter applied, then widens
// it if that leaves too few rows to fill the viewport (see
// ensureScrollable()). filterYear is handled separately above (it
// swaps the whole loading strategy, not just which filter params
// get sent). Each also reflects into the URL hash -- see syncHash().
filterCompte() {
this.onFilterChanged();
},
filterClient() {
this.onFilterChanged();
},
filterType() {
this.onFilterChanged();
},
filterFlag() {
this.onFilterChanged();
},
// Debounced (unlike the other filters above) -- this one changes on
// every keystroke, and each change is a network round-trip now,
// not a free client-side recompute.
filterQ() {
clearTimeout(this._filterQDebounce);
this._filterQDebounce = setTimeout(() => this.onFilterChanged(), 350);
},
onlyErrors() {
this.onFilterChanged();
},
onlyFlagged() {
this.onFilterChanged();
},
},
async mounted() {
// Reproduce whatever the URL hash describes -- reload or a shared
// link should land on the same filtered view. The basic filters are
// all sent as query params on the very first load() below, so
// setting them here first (before deciding how to load) means that
// first fetch already reflects the restored view instead of
// loading unfiltered and re-fetching a moment later; filterYear/
// aller each own their loading path (enterYearMode()/jumpToYear()),
// so at most one of those runs instead of the default load().
const hashState = readHashState();
this.filterCompte = hashState.filterCompte;
this.filterClient = hashState.filterClient;
this.filterType = hashState.filterType;
this.filterFlag = hashState.filterFlag;
this.filterQ = hashState.filterQ;
this.onlyErrors = hashState.onlyErrors;
this.onlyFlagged = hashState.onlyFlagged;
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(() => {});
fetchFlagNames().then((names) => { this.allFlagsList = names; }).catch(() => {});
fetchYearsList().then((years) => { this.allYearsList = years; }).catch(() => {});
fetchOuvertureEcarts().then((ecarts) => { this.ouvertureEcarts = ecarts; }).catch(() => {});
jQuery(document).on('dialog:afterclose', () => this.reloadWindow());
this.lastPollTs = Math.floor(Date.now() / 1000);
this.startPolling();
},
};
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);