Files

1874 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,
// Visible écart = non-zero to the centime (a 0.01 mismatch is
// savable -- the blocking threshold stays at 0.01 server-side --
// but must still be shown; mirrors LedgerRowsController's
// hasError). 0.005 guards against float representation of
// stored centimes.
hasError: Math.abs(ecart) > 0.005,
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);