Three related changes to the /lignes table:
1. The "Client" filter is now a text input with a <datalist> instead
of a <select> -- 50+ clients made the dropdown unwieldy. v-model.lazy
(not the default per-keystroke binding) since a change here triggers
ensureScrollable() and a hash rewrite, which shouldn't fire on every
character typed.
2. Clicking a "versement freelance" row's own status badge (Non liée /
Reste à verser / Sur-versé) now drills down the same way an entrée's
"N sorties liées" badge already did, instead of only being clickable
from the entrée side.
3. field_entree_liee is now multi-value (cardinality unlimited) --
sometimes one payment covers several client invoices at once.
LinkEntreeForm uses #tags => TRUE (a single comma-separated
autocomplete field, Drupal's field-API-native multi-value shape on
submit, no manual tag parsing needed). This is the deeper change and
touches most of the reconciliation logic in home.js:
- buildRows() reads field_entree_liee as an array
(entreeLieeIds/entreeLieeLabels) -- JSON:API always returns a list
for a multi-cardinality relationship now, even with 0 or 1 items.
- sortiesByEntree indexes a sortie under every entrée it links to.
- reconciliationByEntree splits a multi-linked sortie's répartition
equally across its linked entrées -- there's no per-link amount to
divide by, so equal split is the least-wrong assumption available
rather than counting the sortie's full amount against every linked
entrée (which would double-count the same money).
- versementStatus() sums residuals across all of a versement's linked
entrées for its own compte(s), skipping any not in the currently
loaded window (same accepted trade-off reconciliationByEntree
already had).
- The drill-down (filterEntreeId) is now filterEntreeGroup, a
transitive closure over shared entrée<->sortie links -- clicking
one entrée (or, per #2, one versement) surfaces every other entrée
it's connected to through a shared sortie, and every sortie linked
to any of them, not just the originally-clicked one's direct links.
Verified end-to-end: linked a real unlinked versement to two entrées
for the same client via the actual form submission (no manual DB
edit), confirmed both persisted, confirmed the link button's tooltip
lists both, and confirmed clicking either the versement's or an
entrée's badge produces the same 3-row connected group with correct
drill-down footer totals. Reverted the test link afterward.
1105 lines
49 KiB
JavaScript
1105 lines
49 KiB
JavaScript
/**
|
|
* @file
|
|
* Progressive decoupling: Drupal renders the page shell (nav, auth via
|
|
* session cookie, the "Ajouter une ligne" modal form); this Vue app fetches
|
|
* JSON:API and renders a spreadsheet-like table of every ligne comptable,
|
|
* with filters, month/year grouping, and per-row écart (répartition sum vs
|
|
* montant HT) highlighting -- inconsistencies are shown, not hidden.
|
|
*
|
|
* Sliding window: with 5+ years of migrated history (~1500+ lines), loading
|
|
* everything up front took ~40s and made the tab briefly unresponsive.
|
|
* Instead of one big fetch, `rows` only ever holds a date-range window
|
|
* (initially the ~18 months around today), extended by ~6 months whenever
|
|
* the user scrolls near the top or bottom edge (IntersectionObserver on two
|
|
* sentinel rows), and trimmed from the far end once the window exceeds
|
|
* MAX_LOADED_MONTHS so it stays a genuine sliding buffer, not an
|
|
* ever-growing list. The "Année" filter and the totals footer can't be
|
|
* computed from a partial window, so they're backed by their own small
|
|
* server endpoints (LedgerStatsController) instead.
|
|
*/
|
|
(function (Drupal, Vue, jQuery) {
|
|
'use strict';
|
|
|
|
const API_BASE = '/jsonapi/node/ligne_comptable';
|
|
const EUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' });
|
|
const MONTHS = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
|
|
const TYPE_LABELS = {
|
|
entree: 'Entrée client',
|
|
charge: 'Charge structurelle',
|
|
versement: 'Versement freelance',
|
|
achat: 'Achat client',
|
|
hebergement: 'Hébergement',
|
|
sous_traitant: 'Sous-traitant',
|
|
salaire_stage: 'Salaire / stage',
|
|
charges_local_pro: 'Charges local pro',
|
|
autre: 'Autre',
|
|
ouverture: 'Ouverture',
|
|
};
|
|
// Sorties that can be linked to the entrée client they pay out against
|
|
// (field_entree_liee) -- charge/autre/ouverture/salaire_stage/
|
|
// charges_local_pro aren't client-specific.
|
|
const LINKABLE_TYPES = ['versement', 'achat', 'hebergement', 'sous_traitant'];
|
|
|
|
const WINDOW_MONTHS = 9; // each side of "today", for the initial load
|
|
const EXTEND_MONTHS = 6; // increment per scroll-triggered load
|
|
const MAX_LOADED_MONTHS = 30; // trim the far end once the window exceeds this
|
|
// While a thinning filter (compte/client/type/écarts) is active, the cap
|
|
// above is actively counter-productive: extending one end and trimming
|
|
// the other nets out to roughly the same 30-month slice every round
|
|
// (see ensureScrollable()), so a filter with only a handful of matches
|
|
// per year can search almost forever without ever surfacing more of
|
|
// them. Comfortably wider than the whole migrated dataset (2021-today)
|
|
// so a filter effectively gets the entire history to search, still
|
|
// bounded (not literally unbounded memory) -- and DOM rendering cost
|
|
// stays proportional to filteredRows, not this raw fetched count.
|
|
const MAX_LOADED_MONTHS_FILTERED = 96;
|
|
// Hard cap on ensureScrollable()'s recursion (see below) -- comfortably
|
|
// more than enough rounds to walk the entire MIN_LOADABLE_DATE..
|
|
// MAX_LOADABLE_DATE span at EXTEND_MONTHS per round.
|
|
const MAX_ENSURE_SCROLLABLE_ROUNDS = 30;
|
|
|
|
// Absolute bounds on how far the window can extend. Without these,
|
|
// loadOlder()/loadNewer() kept pushing windowStart/windowEnd outward
|
|
// even when a fetch came back empty (e.g. extending into future dates
|
|
// with no data yet) -- since nothing about the table's height or
|
|
// scroll position changes when 0 rows come back, checkEdges() stayed
|
|
// satisfied and the next scroll/layout tick fired the exact same load
|
|
// again, drifting further out forever with no way to stop. Comfortably
|
|
// before the earliest migrated year (2021) and well past any plausible
|
|
// future-dated entry.
|
|
const MIN_LOADABLE_DATE = '2020-06-01';
|
|
const MAX_LOADABLE_DATE = addMonths(todayStr(), 24);
|
|
|
|
function todayStr() {
|
|
return new Date().toISOString().slice(0, 10);
|
|
}
|
|
|
|
function addMonths(dateStr, n) {
|
|
const d = new Date(dateStr + 'T00:00:00');
|
|
d.setMonth(d.getMonth() + n);
|
|
return d.toISOString().slice(0, 10);
|
|
}
|
|
|
|
function monthsBetween(a, b) {
|
|
const da = new Date(a + 'T00:00:00');
|
|
const db = new Date(b + 'T00:00:00');
|
|
return (db.getFullYear() - da.getFullYear()) * 12 + (db.getMonth() - da.getMonth());
|
|
}
|
|
|
|
// Builds the JSON:API URL for a half-open date range [start, end).
|
|
function rangeUrl(start, end) {
|
|
const filter =
|
|
'filter[dateRange][group][conjunction]=AND' +
|
|
'&filter[gte][condition][path]=field_date_ligne' +
|
|
'&filter[gte][condition][operator]=%3E%3D' +
|
|
'&filter[gte][condition][value]=' + start +
|
|
'&filter[gte][condition][memberOf]=dateRange' +
|
|
'&filter[lt][condition][path]=field_date_ligne' +
|
|
'&filter[lt][condition][operator]=%3C' +
|
|
'&filter[lt][condition][value]=' + end +
|
|
'&filter[lt][condition][memberOf]=dateRange';
|
|
return API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client,field_entree_liee'
|
|
+ '&page[limit]=50&sort=field_date_ligne,drupal_internal__nid&' + filter;
|
|
}
|
|
|
|
async function fetchLignes(url) {
|
|
// sort includes drupal_internal__nid as a tie-breaker: field_date_ligne
|
|
// alone is not unique (many lines share a date), and without a unique
|
|
// secondary sort key, offset pagination can silently duplicate or skip
|
|
// rows across pages.
|
|
const allData = [];
|
|
const includedMap = new Map();
|
|
while (url) {
|
|
const res = await fetch(url, { headers: { Accept: 'application/vnd.api+json' } });
|
|
if (!res.ok) throw new Error('JSON:API a répondu ' + res.status);
|
|
const json = await res.json();
|
|
allData.push(...(json.data || []));
|
|
(json.included || []).forEach((item) => includedMap.set(item.type + ':' + item.id, item));
|
|
url = json.links && json.links.next ? json.links.next.href : null;
|
|
}
|
|
// Defensive de-dup by node id, in case pagination ever repeats a row.
|
|
const seen = new Set();
|
|
const dedup = allData.filter((n) => (seen.has(n.id) ? false : (seen.add(n.id), true)));
|
|
return { data: dedup, includedMap };
|
|
}
|
|
|
|
async function fetchClientNames() {
|
|
const res = await fetch('/jsonapi/taxonomy_term/client?sort=name&page[limit]=200', { headers: { Accept: 'application/vnd.api+json' } });
|
|
if (!res.ok) throw new Error('JSON:API a répondu ' + res.status);
|
|
const json = await res.json();
|
|
return (json.data || []).map((t) => t.attributes.name).filter(Boolean).sort();
|
|
}
|
|
|
|
async function fetchYearsList() {
|
|
const res = await fetch('/lignes/api/annees', { headers: { Accept: 'application/json' } });
|
|
if (!res.ok) throw new Error('/lignes/api/annees a répondu ' + res.status);
|
|
const json = await res.json();
|
|
return json.annees || [];
|
|
}
|
|
|
|
// { "2023": { "Bachir": -0.03, ... }, ... } -- years where the actual
|
|
// ouverture doesn't match the previous year's calculated closing
|
|
// balance (that year's own ouverture + every movement dated within
|
|
// it). Small dataset (a handful of year boundaries), fetched once.
|
|
async function fetchOuvertureEcarts() {
|
|
const res = await fetch('/lignes/api/reconciliation-ouverture', { headers: { Accept: 'application/json' } });
|
|
if (!res.ok) throw new Error('/lignes/api/reconciliation-ouverture a répondu ' + res.status);
|
|
return res.json();
|
|
}
|
|
|
|
// POST /lignes/{nid}/type -- change field_type_ligne without opening the
|
|
// full edit form. Fetches a fresh CSRF token each time (core's
|
|
// /session/token) rather than caching one -- this is an occasional
|
|
// action, not worth the complexity of handling a stale cached token.
|
|
async function updateLigneType(nid, type) {
|
|
const tokenRes = await fetch('/session/token');
|
|
const token = await tokenRes.text();
|
|
const res = await fetch('/lignes/' + nid + '/type', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token },
|
|
body: JSON.stringify({ type }),
|
|
});
|
|
const json = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(json.error || ('/lignes/' + nid + '/type a répondu ' + res.status));
|
|
return json;
|
|
}
|
|
|
|
async function fetchYearTotals(annee) {
|
|
const res = await fetch('/lignes/api/totaux?annee=' + encodeURIComponent(annee), { headers: { Accept: 'application/json' } });
|
|
if (!res.ok) throw new Error('/lignes/api/totaux a répondu ' + res.status);
|
|
return res.json();
|
|
}
|
|
|
|
// URL hash (#compte=Maud&type=versement&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.
|
|
// "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') || '',
|
|
filterClient: params.get('client') || '',
|
|
filterType: params.get('type') || '',
|
|
filterYear: params.get('annee') || '',
|
|
onlyErrors: params.get('ecarts') === '1',
|
|
aller: params.get('aller') || '',
|
|
};
|
|
}
|
|
|
|
function buildHashString(state) {
|
|
const params = new URLSearchParams();
|
|
if (state.filterCompte) params.set('compte', state.filterCompte);
|
|
if (state.filterClient) params.set('client', state.filterClient);
|
|
if (state.filterType) params.set('type', state.filterType);
|
|
if (state.filterYear) params.set('annee', state.filterYear);
|
|
if (state.onlyErrors) params.set('ecarts', '1');
|
|
// Redundant/ambiguous alongside an active "Année" filter -- that
|
|
// already fully describes the year, so don't also carry a stale
|
|
// "aller" target into the hash.
|
|
if (state.lastJumpYear && !state.filterYear) params.set('aller', state.lastJumpYear);
|
|
return params.toString();
|
|
}
|
|
|
|
function resolve(includedMap, ref) {
|
|
if (!ref) return null;
|
|
return includedMap.get(ref.type + ':' + ref.id) || null;
|
|
}
|
|
|
|
function buildRows(data, includedMap) {
|
|
const rows = [];
|
|
for (const node of data) {
|
|
const rels = node.relationships || {};
|
|
const attrs = node.attributes;
|
|
const clientTerm = resolve(includedMap, rels.field_client && rels.field_client.data);
|
|
// field_entree_liee is multi-value (a single payment sometimes
|
|
// covers several client invoices at once) -- JSON:API always
|
|
// returns an array for a multi-cardinality relationship, one ref
|
|
// per linked entrée, even when there's only one or none.
|
|
const entreeLieeRefs = (rels.field_entree_liee && rels.field_entree_liee.data) || [];
|
|
const entreeLieeNodes = entreeLieeRefs.map((ref) => resolve(includedMap, ref)).filter(Boolean);
|
|
const parCompte = {};
|
|
let somme = 0;
|
|
const repartitionRefs = (rels.field_repartition && rels.field_repartition.data) || [];
|
|
for (const ref of repartitionRefs) {
|
|
const paragraph = resolve(includedMap, ref);
|
|
if (!paragraph) continue;
|
|
const montant = parseFloat(paragraph.attributes.field_montant || 0);
|
|
const compteTerm = resolve(includedMap, paragraph.relationships && paragraph.relationships.field_compte && paragraph.relationships.field_compte.data);
|
|
const compteName = compteTerm ? compteTerm.attributes.name : '(compte inconnu)';
|
|
parCompte[compteName] = (parCompte[compteName] || 0) + montant;
|
|
somme += montant;
|
|
}
|
|
const montantHt = attrs.field_montant_ht !== null && attrs.field_montant_ht !== undefined ? parseFloat(attrs.field_montant_ht) : null;
|
|
const ecart = montantHt !== null ? Math.round((montantHt - somme) * 100) / 100 : 0;
|
|
rows.push({
|
|
id: node.id,
|
|
nid: attrs.drupal_internal__nid,
|
|
date: attrs.field_date_ligne,
|
|
type: attrs.field_type_ligne,
|
|
client: clientTerm ? clientTerm.attributes.name : null,
|
|
libelle: attrs.field_notes || attrs.title,
|
|
montant_ht: montantHt,
|
|
montant_ttc: attrs.field_montant_ttc !== null && attrs.field_montant_ttc !== undefined ? parseFloat(attrs.field_montant_ttc) : null,
|
|
parCompte,
|
|
somme,
|
|
ecart,
|
|
hasError: Math.abs(ecart) > 0.01,
|
|
linkable: LINKABLE_TYPES.includes(attrs.field_type_ligne),
|
|
entreeLieeIds: entreeLieeNodes.map((n) => n.id),
|
|
entreeLieeLabels: entreeLieeNodes.map((n) => n.attributes.title || n.id),
|
|
});
|
|
}
|
|
rows.sort((a, b) => (a.date || '').localeCompare(b.date || ''));
|
|
return rows;
|
|
}
|
|
|
|
const App = {
|
|
data() {
|
|
return {
|
|
loading: true,
|
|
error: null,
|
|
rows: [],
|
|
allComptes: ['Sandrine', 'Maud', 'Ouidade', 'Chloé', 'Bachir', 'Valentin', 'EXT.', 'EXT.WEB'],
|
|
allClientsList: [],
|
|
allYearsList: [],
|
|
ouvertureEcarts: {},
|
|
filterCompte: '',
|
|
filterClient: '',
|
|
filterType: '',
|
|
filterYear: '',
|
|
jumpYearValue: '',
|
|
// Not a filter itself (jumpYearValue always resets to '' right
|
|
// after firing) -- just remembers the last "Aller à" target so
|
|
// the URL hash can reflect it. See syncHash()/readHashState().
|
|
lastJumpYear: '',
|
|
groupBy: 'month',
|
|
onlyErrors: false,
|
|
hoverCol: null,
|
|
filterEntreeId: null,
|
|
// Which row's type badge is currently showing its inline <select>
|
|
// instead of the badge -- only one at a time.
|
|
editingTypeId: null,
|
|
typeUpdateError: null,
|
|
// Fenêtre glissante.
|
|
windowStart: null,
|
|
windowEnd: null,
|
|
loadingOlder: false,
|
|
loadingNewer: false,
|
|
// True while EITHER loadOlder() or loadNewer() is running -- they
|
|
// both mutate `rows`/windowStart/windowEnd, and a scroll-position
|
|
// compensation write inside one (from trimming the far end) fires
|
|
// a real 'scroll' event that can kick off the other direction
|
|
// while the first is still in flight. Without a shared lock, the
|
|
// two interleave and race on the same state -- observed as `rows`
|
|
// transiently emptying out (table blinks away) before settling.
|
|
// loadingOlder/loadingNewer stay separate for the sentinel-row
|
|
// text; this gates actual execution.
|
|
loadingWindow: false,
|
|
// Footer : totaux de l'année actuellement visible à l'écran (pas
|
|
// de la fenêtre chargée), voir detectCurrentYear().
|
|
currentYear: null,
|
|
currentYearTotals: null,
|
|
currentYearLoading: false,
|
|
};
|
|
},
|
|
computed: {
|
|
allTypes() {
|
|
return Object.entries(TYPE_LABELS).map(([value, label]) => ({ value, label }));
|
|
},
|
|
errorCount() {
|
|
return this.rows.filter((r) => r.hasError).length;
|
|
},
|
|
// 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.rows) {
|
|
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. Limited to the currently loaded
|
|
// window -- a sortie linked to an entrée outside it won't be
|
|
// counted (accepted trade-off of the sliding window).
|
|
//
|
|
// 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.rows) {
|
|
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) --
|
|
// versementStatus() 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.
|
|
filterEntreeGroup() {
|
|
if (!this.filterEntreeId) return null;
|
|
const entreeIds = new Set([this.filterEntreeId]);
|
|
let grown = true;
|
|
while (grown) {
|
|
grown = false;
|
|
for (const r of this.rows) {
|
|
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 = this.rows.filter((r) => r.type === 'entree' && entreeIds.has(r.id));
|
|
const sorties = this.rows.filter((r) => r.type !== 'entree' && r.entreeLieeIds.some((id) => entreeIds.has(id)));
|
|
return [...entrees, ...sorties];
|
|
},
|
|
filteredRows() {
|
|
// Drill-down mode: the connected entrée/sortie group, ignoring
|
|
// the other filters -- clicking the badge again clears it.
|
|
if (this.filterEntreeId) {
|
|
return this.filterEntreeGroup;
|
|
}
|
|
return this.rows.filter((r) => {
|
|
if (this.filterCompte && r.parCompte[this.filterCompte] === undefined) return false;
|
|
if (this.filterClient && r.client !== this.filterClient) return false;
|
|
if (this.filterType && r.type !== this.filterType) return false;
|
|
if (this.filterYear && (r.date || '').slice(0, 4) !== this.filterYear) return false;
|
|
if (this.onlyErrors && !r.hasError) return false;
|
|
return true;
|
|
});
|
|
},
|
|
// Footer totals while drilled down into one entrée + its linked
|
|
// sorties (see filteredRows' drill-down branch above): the whole
|
|
// point of this view is "does this entrée balance against what was
|
|
// paid out", so the footer should answer 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.filteredRows) {
|
|
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)])),
|
|
};
|
|
},
|
|
// What the footer actually renders -- the drill-down's own totals
|
|
// while active, otherwise the current year's (see
|
|
// detectCurrentYear()/loadCurrentYearTotals()). Switches back
|
|
// automatically the moment filterEntreeId clears.
|
|
footerTotals() {
|
|
return this.filterEntreeId ? this.drilldownTotals : this.currentYearTotals;
|
|
},
|
|
groupedRows() {
|
|
const list = this.filteredRows;
|
|
if (this.groupBy === 'none') return list;
|
|
const groups = new Map();
|
|
for (const r of list) {
|
|
const d = r.date ? new Date(r.date + 'T00:00:00') : null;
|
|
let key, label;
|
|
if (this.groupBy === 'year') {
|
|
key = d ? String(d.getFullYear()) : '?';
|
|
label = key;
|
|
} else {
|
|
key = d ? d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') : '?';
|
|
label = d ? MONTHS[d.getMonth()] + ' ' + d.getFullYear() : 'Date inconnue';
|
|
}
|
|
if (!groups.has(key)) groups.set(key, { label, year: d ? String(d.getFullYear()) : null, rows: [] });
|
|
groups.get(key).rows.push(r);
|
|
}
|
|
const sortedKeys = Array.from(groups.keys()).sort();
|
|
const out = [];
|
|
for (const key of sortedKeys) {
|
|
const g = groups.get(key);
|
|
out.push({ isGroup: true, key: 'g-' + key, label: g.label, count: g.rows.length, year: g.year });
|
|
for (const r of g.rows) out.push(Object.assign({ key: 'r-' + r.id }, r));
|
|
}
|
|
return out;
|
|
},
|
|
},
|
|
methods: {
|
|
formatEur(v) {
|
|
return v === null || v === undefined ? '' : EUR.format(v);
|
|
},
|
|
// Écart between this ouverture row's year and the previous year's
|
|
// calculated closing balance -- restricted to whichever compte(s)
|
|
// this specific row's répartition actually touches (ouverture lines
|
|
// are entered one per compte, so showing every compte's écart on
|
|
// every row would just repeat the same list). Null if this row's
|
|
// compte(s) reconcile cleanly.
|
|
ouvertureEcart(item) {
|
|
if (item.type !== 'ouverture' || !item.date) return null;
|
|
const annee = item.date.slice(0, 4);
|
|
const ecarts = this.ouvertureEcarts[annee];
|
|
if (!ecarts) return null;
|
|
const relevant = Object.keys(item.parCompte).filter((c) => ecarts[c] !== undefined);
|
|
if (!relevant.length) return null;
|
|
const detail = relevant.map((compte) => compte + ' : ' + this.formatEur(ecarts[compte])).join(', ');
|
|
return { detail, comptes: relevant.length };
|
|
},
|
|
// Flags a "versement freelance" row that isn't (fully) backed by
|
|
// the entrée client(s) it pays out against: either not linked at
|
|
// all, or linked but its own compte(s) still show a residual
|
|
// against at least one of them. Deliberately scoped to just the
|
|
// compte(s) this versement's own répartition touches
|
|
// (reconciliationByEntree's parCompteResidual), not the entrée's
|
|
// overall resteAVerser/surVerse -- those can be driven entirely by
|
|
// a *different* compte tied to some other sortie linked to the
|
|
// same entrée, which says nothing about whether this versement'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.
|
|
// Entrées outside the currently loaded window are skipped (same
|
|
// accepted trade-off as reconciliationByEntree itself); null only
|
|
// if none of them could be checked at all.
|
|
versementStatus(item) {
|
|
if (item.type !== 'versement') return null;
|
|
if (!item.entreeLieeIds.length) {
|
|
return { kind: 'non-liee', detail: 'Aucune entrée client liée.' };
|
|
}
|
|
let resteAVerser = 0;
|
|
let surVerse = 0;
|
|
let checked = 0;
|
|
for (const entreeId of item.entreeLieeIds) {
|
|
const recon = this.reconciliationByEntree.get(entreeId);
|
|
if (!recon) continue;
|
|
checked++;
|
|
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 (!checked) return null;
|
|
if (resteAVerser > 0.01) {
|
|
return { kind: 'reste', detail: 'Reste à verser (comptes de cette ligne) : ' + this.formatEur(Math.round(resteAVerser * 100) / 100) };
|
|
}
|
|
if (surVerse > 0.01) {
|
|
return { kind: 'sur-verse', detail: 'Sur-versé (comptes de cette ligne) : ' + this.formatEur(Math.round(surVerse * 100) / 100) };
|
|
}
|
|
return null;
|
|
},
|
|
versementStatusLabel(kind) {
|
|
if (kind === 'non-liee') return 'Non liée';
|
|
if (kind === 'reste') return 'Reste à verser';
|
|
return 'Sur-versé';
|
|
},
|
|
// jj/mm/aa -- shorter than the API's ISO yyyy-mm-dd, saves column
|
|
// width in a table already packed with 8 compte columns.
|
|
formatDate(iso) {
|
|
if (!iso) return '';
|
|
const [y, m, d] = iso.split('-');
|
|
return d + '/' + m + '/' + y.slice(2);
|
|
},
|
|
typeLabel(t) {
|
|
return TYPE_LABELS[t] || t;
|
|
},
|
|
soldeClass(v) {
|
|
if (v > 0.5) return 'figli-solde-crediteur';
|
|
if (v < -0.5) return 'figli-solde-debiteur';
|
|
return '';
|
|
},
|
|
// Single colored anchor per row (Montant HT only, not every compte
|
|
// column) so entrées/sorties are scannable at a glance without the
|
|
// table turning into a red/green garland. No 0.5€ threshold like
|
|
// soldeClass -- individual lines are often small (e.g. a -1.07€ OVH
|
|
// renewal), any nonzero sign should read as positive/negative.
|
|
montantClass(v) {
|
|
if (v > 0) return 'figli-montant-positif';
|
|
if (v < 0) return 'figli-montant-negatif';
|
|
return '';
|
|
},
|
|
openAddForm() {
|
|
Drupal.ajax({
|
|
url: '/node/add/ligne_comptable',
|
|
dialogType: 'modal',
|
|
dialog: { width: 800, title: 'Ajouter une ligne comptable' },
|
|
progress: { type: 'throbber' },
|
|
}).execute();
|
|
},
|
|
openEditForm(nid) {
|
|
Drupal.ajax({
|
|
url: '/node/' + nid + '/edit',
|
|
dialogType: 'modal',
|
|
dialog: { width: 800, title: 'Modifier la ligne comptable' },
|
|
progress: { type: 'throbber' },
|
|
}).execute();
|
|
},
|
|
openLinkForm(nid) {
|
|
Drupal.ajax({
|
|
url: '/lignes/' + nid + '/lier',
|
|
dialogType: 'modal',
|
|
dialog: { width: 500, title: 'Lier à une entrée client' },
|
|
progress: { type: 'throbber' },
|
|
}).execute();
|
|
},
|
|
toggleEntreeFilter(id) {
|
|
this.filterEntreeId = this.filterEntreeId === id ? null : id;
|
|
},
|
|
startEditType(item) {
|
|
this.typeUpdateError = null;
|
|
this.editingTypeId = item.id;
|
|
},
|
|
// Optimistically patches the row in `rows` (not the transient
|
|
// `item` from groupedRows -- that object is rebuilt from scratch on
|
|
// every computed re-evaluation, so mutating it wouldn't stick) for
|
|
// instant feedback, then reconciles with the server in the
|
|
// background via reloadWindow() (picks up anything else the save
|
|
// touched, e.g. a cleared field_entree_liee).
|
|
async saveType(item, event) {
|
|
const newType = event.target.value;
|
|
this.editingTypeId = null;
|
|
if (newType === item.type) return;
|
|
try {
|
|
const result = await updateLigneType(item.nid, newType);
|
|
const row = this.rows.find((r) => r.id === item.id);
|
|
if (row) {
|
|
row.type = newType;
|
|
row.linkable = LINKABLE_TYPES.includes(newType);
|
|
if (result.entree_liee_cleared) {
|
|
row.entreeLieeIds = [];
|
|
row.entreeLieeLabels = [];
|
|
}
|
|
}
|
|
this.reloadWindow();
|
|
} catch (err) {
|
|
this.typeUpdateError = err.message;
|
|
}
|
|
},
|
|
onCellHover(evt) {
|
|
const cell = evt.target.closest('td, th');
|
|
if (!cell) return;
|
|
// No crosshair on the technical action columns (link/edit icons) --
|
|
// there's nothing to compare across rows there.
|
|
if (cell.classList.contains('actions-col')) {
|
|
this.clearColHover();
|
|
return;
|
|
}
|
|
// Logical column position, not DOM sibling index: the totals row's
|
|
// first cell has colspan="4", which shifts every cell.cellIndex
|
|
// after it out of alignment with the body rows.
|
|
const index = this.logicalColIndex(cell);
|
|
if (index === this.hoverCol) return;
|
|
this.setColHover(index);
|
|
},
|
|
clearColHover() {
|
|
this.setColHover(null);
|
|
},
|
|
logicalColIndex(cell) {
|
|
let index = 0;
|
|
let sib = cell.previousElementSibling;
|
|
while (sib) {
|
|
index += sib.colSpan || 1;
|
|
sib = sib.previousElementSibling;
|
|
}
|
|
return index;
|
|
},
|
|
// Whole-column highlight (header + body + footer) to pair with the
|
|
// row hover: plain DOM class toggling rather than a Vue-bound class
|
|
// per cell, since the column count/order is fixed markup here, not
|
|
// data-driven -- no need to thread an index through every <td>.
|
|
setColHover(index) {
|
|
const table = this.$refs.tableEl;
|
|
if (!table) return;
|
|
table.querySelectorAll('.figli-col-hover').forEach((el) => el.classList.remove('figli-col-hover'));
|
|
this.hoverCol = index;
|
|
if (index === null) return;
|
|
table.querySelectorAll('tr').forEach((tr) => {
|
|
let pos = 0;
|
|
for (const cell of tr.children) {
|
|
const span = cell.colSpan || 1;
|
|
if (index >= pos && index < pos + span) {
|
|
cell.classList.add('figli-col-hover');
|
|
break;
|
|
}
|
|
pos += span;
|
|
}
|
|
});
|
|
},
|
|
// showLoading defaults to true for the initial mount, where there's
|
|
// nothing on screen yet to preserve. A post-edit refresh passes
|
|
// false: flipping `loading` back to true would unmount the whole
|
|
// v-else table (the "Chargement…" paragraph takes its place) and
|
|
// remount it from scratch once the fetch resolves -- exactly the
|
|
// full-table flicker/scroll-reset Vue's keyed diffing exists to
|
|
// avoid. Reassigning `rows` in place lets Vue patch just the rows
|
|
// that actually changed.
|
|
async load(showLoading = true) {
|
|
if (showLoading) this.loading = true;
|
|
this.error = null;
|
|
try {
|
|
const today = todayStr();
|
|
this.windowStart = addMonths(today, -WINDOW_MONTHS);
|
|
this.windowEnd = addMonths(today, WINDOW_MONTHS);
|
|
const { data, includedMap } = await fetchLignes(rangeUrl(this.windowStart, this.windowEnd));
|
|
this.rows = buildRows(data, includedMap);
|
|
} catch (err) {
|
|
this.error = err.message;
|
|
} finally {
|
|
if (showLoading) this.loading = false;
|
|
}
|
|
},
|
|
// Post-edit refresh: re-fetch exactly the currently loaded window
|
|
// (not a fresh "last 18 months" window) so editing an old line
|
|
// doesn't silently reset how far the user had scrolled back.
|
|
async reloadWindow() {
|
|
if (!this.windowStart || !this.windowEnd) return;
|
|
try {
|
|
const { data, includedMap } = await fetchLignes(rangeUrl(this.windowStart, this.windowEnd));
|
|
this.rows = buildRows(data, includedMap);
|
|
} catch (err) {
|
|
this.error = err.message;
|
|
}
|
|
},
|
|
async loadOlder() {
|
|
// Bypassed while a specific "Année" filter is active -- that mode
|
|
// loads exactly one year and nothing else (see enterYearMode).
|
|
// Also bypassed once the window already reaches MIN_LOADABLE_DATE --
|
|
// nothing older to fetch, so skip straight out instead of re-firing
|
|
// an empty request on every subsequent scroll/layout tick.
|
|
if (this.loadingWindow || !this.windowStart || this.filterYear || this.windowStart <= MIN_LOADABLE_DATE) return;
|
|
this.loadingWindow = true;
|
|
this.loadingOlder = true;
|
|
try {
|
|
let newStart = addMonths(this.windowStart, -EXTEND_MONTHS);
|
|
if (newStart < MIN_LOADABLE_DATE) newStart = MIN_LOADABLE_DATE;
|
|
const { data, includedMap } = await fetchLignes(rangeUrl(newStart, this.windowStart));
|
|
const newRows = buildRows(data, includedMap);
|
|
this.windowStart = newStart;
|
|
|
|
if (newRows.length) {
|
|
const wrap = this.$refs.tableWrap;
|
|
const prevScrollHeight = wrap ? wrap.scrollHeight : 0;
|
|
this.rows = [...newRows, ...this.rows];
|
|
await this.$nextTick();
|
|
// Prepending pushes existing content down -- keep whatever
|
|
// the user was looking at in the same visual spot.
|
|
if (wrap) wrap.scrollTop += wrap.scrollHeight - prevScrollHeight;
|
|
}
|
|
|
|
// Trim the far (newer) end once the window's grown past the cap
|
|
// -- happens below the current scroll position, so no visual
|
|
// jump to compensate for.
|
|
const maxEnd = addMonths(this.windowStart, this.isFiltering() ? MAX_LOADED_MONTHS_FILTERED : MAX_LOADED_MONTHS);
|
|
if (this.windowEnd > maxEnd) {
|
|
this.rows = this.rows.filter((r) => r.date < maxEnd);
|
|
this.windowEnd = maxEnd;
|
|
}
|
|
} catch (err) {
|
|
this.error = err.message;
|
|
} finally {
|
|
this.loadingOlder = false;
|
|
this.loadingWindow = false;
|
|
}
|
|
},
|
|
async loadNewer() {
|
|
// Same MIN_LOADABLE_DATE reasoning as loadOlder(), mirrored at the
|
|
// future end.
|
|
if (this.loadingWindow || !this.windowEnd || this.filterYear || this.windowEnd >= MAX_LOADABLE_DATE) return;
|
|
this.loadingWindow = true;
|
|
this.loadingNewer = true;
|
|
try {
|
|
let newEnd = addMonths(this.windowEnd, EXTEND_MONTHS);
|
|
if (newEnd > MAX_LOADABLE_DATE) newEnd = MAX_LOADABLE_DATE;
|
|
const { data, includedMap } = await fetchLignes(rangeUrl(this.windowEnd, newEnd));
|
|
const newRows = buildRows(data, includedMap);
|
|
this.windowEnd = newEnd;
|
|
|
|
if (newRows.length) {
|
|
// Appended below the viewport -- no scroll compensation needed.
|
|
this.rows = [...this.rows, ...newRows];
|
|
}
|
|
|
|
// Trim the far (older) end once past the cap -- this IS above
|
|
// the viewport, so compensate scrollTop the same way loadOlder
|
|
// does for its prepend.
|
|
const minStart = addMonths(this.windowEnd, this.isFiltering() ? -MAX_LOADED_MONTHS_FILTERED : -MAX_LOADED_MONTHS);
|
|
if (this.windowStart < minStart) {
|
|
const wrap = this.$refs.tableWrap;
|
|
const prevScrollHeight = wrap ? wrap.scrollHeight : 0;
|
|
const prevScrollTop = wrap ? wrap.scrollTop : 0;
|
|
this.rows = this.rows.filter((r) => r.date >= minStart);
|
|
this.windowStart = minStart;
|
|
await this.$nextTick();
|
|
if (wrap) wrap.scrollTop = prevScrollTop - (prevScrollHeight - wrap.scrollHeight);
|
|
}
|
|
} catch (err) {
|
|
this.error = err.message;
|
|
} finally {
|
|
this.loadingNewer = false;
|
|
this.loadingWindow = false;
|
|
}
|
|
},
|
|
// Scroll-position based rather than IntersectionObserver: one
|
|
// unified scroll handler drives both edge-loading and
|
|
// detectCurrentYear() below, instead of maintaining two separate
|
|
// observer instances for what's fundamentally the same "where is
|
|
// the user looking right now" question.
|
|
checkEdges() {
|
|
const wrap = this.$refs.tableWrap;
|
|
if (!wrap) return;
|
|
const threshold = 400;
|
|
// No overflow at all -- typically a filter (compte/client/type/
|
|
// écarts) has thinned the visible rows enough that the loaded
|
|
// window's worth of matches doesn't fill the viewport. scrollTop
|
|
// is stuck at 0 either way, so neither of the two checks below
|
|
// means anything on its own; try both directions instead of
|
|
// waiting for a scroll that can never happen with nothing to
|
|
// scroll.
|
|
const notScrollable = wrap.scrollHeight <= wrap.clientHeight;
|
|
if (notScrollable || wrap.scrollTop < threshold) this.loadOlder();
|
|
if (notScrollable || wrap.scrollTop + wrap.clientHeight > wrap.scrollHeight - threshold) this.loadNewer();
|
|
},
|
|
// Whether compte/client/type/écarts thin filteredRows down from
|
|
// whatever's actually loaded -- loadOlder()/loadNewer() use this to
|
|
// widen the trim cap (MAX_LOADED_MONTHS_FILTERED instead of
|
|
// MAX_LOADED_MONTHS), since the normal cap actively works against a
|
|
// sparse filter: extending one end and immediately trimming the
|
|
// other off nets out to almost the same slice every round, so a
|
|
// filter with only a few matches a year could search almost forever
|
|
// without surfacing more of them.
|
|
isFiltering() {
|
|
return !!(this.filterCompte || this.filterClient || this.filterType || this.onlyErrors);
|
|
},
|
|
// Keeps extending the window (both directions) as long as a filter
|
|
// leaves too few matching rows to fill the viewport -- otherwise
|
|
// there's never another scroll event to hang further loading off
|
|
// of, and the sliding window silently stops responding the moment a
|
|
// filter (compte/client/type/écarts) makes the visible page short
|
|
// enough to not need a scrollbar.
|
|
//
|
|
// Bounded by a round count regardless, as a safety net: addMonths()
|
|
// uses JS's Date#setMonth(), which isn't invertible for month-end
|
|
// dates (e.g. 2025-03-31 minus 1 month lands on 2025-03-03, not
|
|
// 2025-02-28, because setMonth(1) overflows February), so even with
|
|
// the widened cap above, "no change since last round" isn't a
|
|
// guaranteed way to detect "there's genuinely nothing more to find".
|
|
async ensureScrollable(round = 0) {
|
|
if (this.filterYear || round >= MAX_ENSURE_SCROLLABLE_ROUNDS) return;
|
|
const wrap = this.$refs.tableWrap;
|
|
if (!wrap) return;
|
|
await this.$nextTick();
|
|
if (wrap.scrollHeight > wrap.clientHeight) return;
|
|
const beforeStart = this.windowStart;
|
|
const beforeEnd = this.windowEnd;
|
|
await this.loadOlder();
|
|
await this.loadNewer();
|
|
if (this.windowStart === beforeStart && this.windowEnd === beforeEnd) return;
|
|
await this.ensureScrollable(round + 1);
|
|
},
|
|
// Which year is "at the top" of the visible area right now (just
|
|
// under the sticky thead) -- drives the totals footer. Works
|
|
// regardless of grouping mode: every rendered row (group header or
|
|
// data row) carries a data-year attribute.
|
|
detectCurrentYear() {
|
|
const wrap = this.$refs.tableWrap;
|
|
const table = this.$refs.tableEl;
|
|
if (!wrap || !table) return;
|
|
const thead = table.querySelector('thead');
|
|
const wrapRect = wrap.getBoundingClientRect();
|
|
const thresholdY = wrapRect.top + (thead ? thead.getBoundingClientRect().height : 0) + 2;
|
|
const rows = table.querySelectorAll('tbody tr[data-year]');
|
|
let year = null;
|
|
for (const tr of rows) {
|
|
const rect = tr.getBoundingClientRect();
|
|
if (rect.bottom > thresholdY) {
|
|
year = tr.dataset.year;
|
|
break;
|
|
}
|
|
}
|
|
if (year && year !== this.currentYear) {
|
|
this.currentYear = year;
|
|
this.loadCurrentYearTotals();
|
|
}
|
|
},
|
|
onScroll() {
|
|
if (this._scrollRaf) return;
|
|
this._scrollRaf = requestAnimationFrame(() => {
|
|
this._scrollRaf = null;
|
|
this.detectCurrentYear();
|
|
this.checkEdges();
|
|
});
|
|
},
|
|
// mounted() only runs once, but `v-if="loading"` swaps out the
|
|
// <div ref="tableWrap"> for a fresh DOM node every time loading
|
|
// toggles true -> false (enterYearMode/exitYearMode/jumpToYear all
|
|
// do this) -- the old node's scroll listener goes with it, silently
|
|
// leaving the new one with no listener at all (checkEdges() and
|
|
// detectCurrentYear(), and so the footer, stop responding to
|
|
// scroll). Call this after every such transition, once the new
|
|
// wrap exists; the dataset flag makes it a no-op if the element is
|
|
// unchanged.
|
|
ensureScrollListener() {
|
|
const wrap = this.$refs.tableWrap;
|
|
if (wrap && !wrap.dataset.figliScrollBound) {
|
|
wrap.dataset.figliScrollBound = '1';
|
|
wrap.addEventListener('scroll', this.onScroll, { passive: true });
|
|
}
|
|
},
|
|
async loadCurrentYearTotals() {
|
|
if (!this.currentYear) return;
|
|
this.currentYearLoading = true;
|
|
try {
|
|
this.currentYearTotals = await fetchYearTotals(this.currentYear);
|
|
} catch (err) {
|
|
this.currentYearTotals = null;
|
|
} finally {
|
|
this.currentYearLoading = false;
|
|
}
|
|
},
|
|
// Picking a specific "Année" bypasses the sliding window entirely --
|
|
// load exactly that year (a bounded, small fetch on its own) rather
|
|
// than post-filtering whatever happens to be in the current window,
|
|
// which could easily be empty for a year the user hasn't scrolled
|
|
// to yet.
|
|
async enterYearMode(year) {
|
|
this.loading = true;
|
|
this.error = null;
|
|
try {
|
|
const start = year + '-01-01';
|
|
const end = (parseInt(year, 10) + 1) + '-01-01';
|
|
const { data, includedMap } = await fetchLignes(rangeUrl(start, end));
|
|
this.rows = buildRows(data, includedMap);
|
|
this.windowStart = start;
|
|
this.windowEnd = end;
|
|
} catch (err) {
|
|
this.error = err.message;
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
await this.$nextTick();
|
|
if (this.$refs.tableWrap) this.$refs.tableWrap.scrollTop = 0;
|
|
this.ensureScrollListener();
|
|
this.detectCurrentYear();
|
|
},
|
|
// Back to "Toutes" -- resume the normal sliding window, re-centered
|
|
// on today rather than trying to resume exactly where the year
|
|
// filter was applied from.
|
|
async exitYearMode() {
|
|
await this.load();
|
|
await this.$nextTick();
|
|
const wrap = this.$refs.tableWrap;
|
|
if (wrap) wrap.scrollTop = wrap.scrollHeight;
|
|
this.ensureScrollListener();
|
|
this.detectCurrentYear();
|
|
},
|
|
// "Aller à" -- a one-shot navigation shortcut, distinct from the
|
|
// "Année" filter: it re-centers the sliding window on 1 January of
|
|
// the chosen year and scrolls there, but (unlike filterYear) doesn't
|
|
// restrict the view to just that year -- scrolling up/down still
|
|
// extends the window normally, so you can still see the overlap
|
|
// with neighbouring years.
|
|
onJumpYearChange() {
|
|
const year = this.jumpYearValue;
|
|
this.jumpYearValue = ''; // reset immediately -- this is an action, not a sticky filter
|
|
this.jumpToYear(year);
|
|
},
|
|
async jumpToYear(year) {
|
|
if (!year) return;
|
|
// A year filter would otherwise fight with the window this sets
|
|
// below -- clear it first, silencing the watcher's own
|
|
// exitYearMode() so its (different) window doesn't race this one.
|
|
if (this.filterYear) {
|
|
this._skipYearWatch = true;
|
|
this.filterYear = '';
|
|
}
|
|
this.lastJumpYear = year;
|
|
this.syncHash();
|
|
this.loading = true;
|
|
this.error = null;
|
|
try {
|
|
const yearStart = year + '-01-01';
|
|
this.windowStart = yearStart;
|
|
this.windowEnd = addMonths(yearStart, WINDOW_MONTHS * 2);
|
|
const { data, includedMap } = await fetchLignes(rangeUrl(this.windowStart, this.windowEnd));
|
|
this.rows = buildRows(data, includedMap);
|
|
} catch (err) {
|
|
this.error = err.message;
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
await this.$nextTick();
|
|
const wrap = this.$refs.tableWrap;
|
|
if (wrap) wrap.scrollTop = 0;
|
|
this.ensureScrollListener();
|
|
this.detectCurrentYear();
|
|
this.checkEdges();
|
|
},
|
|
// Reflects the current filters + "Aller à" target in the URL hash
|
|
// (replacing, not pushing, so tweaking a dropdown doesn't spam the
|
|
// back button with history entries) -- reload the page or share the
|
|
// link and readHashState()/mounted() reproduce the same view.
|
|
syncHash() {
|
|
const hash = buildHashString({
|
|
filterCompte: this.filterCompte,
|
|
filterClient: this.filterClient,
|
|
filterType: this.filterType,
|
|
filterYear: this.filterYear,
|
|
onlyErrors: this.onlyErrors,
|
|
lastJumpYear: this.lastJumpYear,
|
|
});
|
|
const url = location.pathname + location.search + (hash ? '#' + hash : '');
|
|
history.replaceState(null, '', url);
|
|
},
|
|
},
|
|
watch: {
|
|
filterYear(newYear, oldYear) {
|
|
if (this._skipYearWatch) {
|
|
this._skipYearWatch = false;
|
|
return;
|
|
}
|
|
if (newYear) {
|
|
this.enterYearMode(newYear);
|
|
} else if (oldYear) {
|
|
this.exitYearMode();
|
|
}
|
|
this.syncHash();
|
|
},
|
|
// Any of these can thin filteredRows enough to remove the
|
|
// scrollbar the sliding window relies on to keep loading -- see
|
|
// ensureScrollable(). filterYear is handled separately above (it
|
|
// swaps the whole loading strategy, not just the visible subset).
|
|
// Each also reflects into the URL hash so the filtered view is
|
|
// reloadable/shareable -- see syncHash().
|
|
filterCompte() {
|
|
this.ensureScrollable();
|
|
this.syncHash();
|
|
},
|
|
filterClient() {
|
|
this.ensureScrollable();
|
|
this.syncHash();
|
|
},
|
|
filterType() {
|
|
this.ensureScrollable();
|
|
this.syncHash();
|
|
},
|
|
onlyErrors() {
|
|
this.ensureScrollable();
|
|
this.syncHash();
|
|
},
|
|
},
|
|
async mounted() {
|
|
// Reproduce whatever the URL hash describes -- reload or a shared
|
|
// link should land on the same filtered view. The four basic
|
|
// filters just narrow filteredRows, so they're safe to set before
|
|
// deciding how to load; filterYear/aller each own their loading
|
|
// path (enterYearMode()/jumpToYear()), so at most one of those
|
|
// runs instead of the default load().
|
|
const hashState = readHashState();
|
|
this.filterCompte = hashState.filterCompte;
|
|
this.filterClient = hashState.filterClient;
|
|
this.filterType = hashState.filterType;
|
|
this.onlyErrors = hashState.onlyErrors;
|
|
|
|
if (hashState.filterYear) {
|
|
this.filterYear = hashState.filterYear;
|
|
} else if (hashState.aller) {
|
|
this.lastJumpYear = hashState.aller;
|
|
this.jumpToYear(hashState.aller);
|
|
} else {
|
|
await this.load();
|
|
await this.$nextTick();
|
|
const wrap = this.$refs.tableWrap;
|
|
this.ensureScrollListener();
|
|
if (wrap) {
|
|
// Start scrolled to the most recent data (the window is
|
|
// centered on "today", so that's the bottom of what's loaded).
|
|
wrap.scrollTop = wrap.scrollHeight;
|
|
}
|
|
this.detectCurrentYear();
|
|
this.checkEdges();
|
|
this.ensureScrollable();
|
|
}
|
|
|
|
// Independent of the row window, so the dropdowns don't shrink to
|
|
// "whatever happens to be loaded right now".
|
|
fetchClientNames().then((names) => { this.allClientsList = names; }).catch(() => {});
|
|
fetchYearsList().then((years) => { this.allYearsList = years; }).catch(() => {});
|
|
fetchOuvertureEcarts().then((ecarts) => { this.ouvertureEcarts = ecarts; }).catch(() => {});
|
|
jQuery(document).on('dialog:afterclose', () => this.reloadWindow());
|
|
},
|
|
};
|
|
|
|
Drupal.behaviors.figliComptaHome = {
|
|
attach(context) {
|
|
const root = context.querySelector ? context.querySelector('#figli-home-app') : null;
|
|
if (root && !root.dataset.figliInitialized) {
|
|
root.dataset.figliInitialized = '1';
|
|
Vue.createApp(App).mount(root);
|
|
}
|
|
},
|
|
};
|
|
})(Drupal, Vue, jQuery);
|