load() unconditionally set loading = true, which unmounts the whole v-else table (the "Chargement…" paragraph takes its place) every time -- including the background refresh after saving a line, which is exactly the scroll-resetting, full-table-disappears flicker Vue's keyed diffing is supposed to prevent. The post-edit refresh (dialog:afterclose) now calls load(false): rows get reassigned in place, and Vue patches only what changed. Verified with a real click (not synthetic JS events, which don't reliably trigger Drupal's mousedown-bound AJAX submit in headless testing): same .figli-table-wrap DOM node before/after, "Chargement…" never appeared. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
376 lines
15 KiB
JavaScript
376 lines
15 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.
|
|
*/
|
|
(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',
|
|
autre: 'Autre',
|
|
ouverture: 'Ouverture',
|
|
};
|
|
// Sorties that can be linked to the entrée client they pay out against
|
|
// (field_entree_liee) -- charge/autre/ouverture aren't client-specific.
|
|
const LINKABLE_TYPES = ['versement', 'achat', 'hebergement'];
|
|
|
|
async function fetchAllLignes() {
|
|
// 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.
|
|
let url = API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client,field_entree_liee&page[limit]=50&sort=field_date_ligne,drupal_internal__nid';
|
|
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 };
|
|
}
|
|
|
|
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);
|
|
const entreeLieeNode = resolve(includedMap, rels.field_entree_liee && rels.field_entree_liee.data);
|
|
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),
|
|
entreeLieeId: entreeLieeNode ? entreeLieeNode.id : null,
|
|
entreeLieeLabel: entreeLieeNode ? (entreeLieeNode.attributes.title || null) : null,
|
|
});
|
|
}
|
|
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'],
|
|
filterCompte: '',
|
|
filterClient: '',
|
|
filterType: '',
|
|
filterYear: '',
|
|
groupBy: 'month',
|
|
onlyErrors: false,
|
|
hoverCol: null,
|
|
filterEntreeId: null,
|
|
};
|
|
},
|
|
computed: {
|
|
allTypes() {
|
|
return Object.entries(TYPE_LABELS).map(([value, label]) => ({ value, label }));
|
|
},
|
|
allClients() {
|
|
return Array.from(new Set(this.rows.map((r) => r.client).filter(Boolean))).sort();
|
|
},
|
|
allYears() {
|
|
return Array.from(new Set(this.rows.map((r) => (r.date || '').slice(0, 4)).filter(Boolean))).sort().reverse();
|
|
},
|
|
errorCount() {
|
|
return this.rows.filter((r) => r.hasError).length;
|
|
},
|
|
sortiesByEntree() {
|
|
const map = new Map();
|
|
for (const r of this.rows) {
|
|
if (!r.entreeLieeId) continue;
|
|
if (!map.has(r.entreeLieeId)) map.set(r.entreeLieeId, []);
|
|
map.get(r.entreeLieeId).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.
|
|
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) {
|
|
for (const [compte, montant] of Object.entries(s.parCompte)) {
|
|
versementsParCompte[compte] = (versementsParCompte[compte] || 0) + montant;
|
|
}
|
|
}
|
|
const comptes = new Set([...Object.keys(entreeRow.parCompte), ...Object.keys(versementsParCompte)]);
|
|
let resteAVerser = 0;
|
|
let surVerse = 0;
|
|
const detail = [];
|
|
for (const c of comptes) {
|
|
const residual = Math.round(((entreeRow.parCompte[c] || 0) + (versementsParCompte[c] || 0)) * 100) / 100;
|
|
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é',
|
|
});
|
|
}
|
|
return map;
|
|
},
|
|
footerTotals() {
|
|
const parCompte = {};
|
|
this.allComptes.forEach((c) => { parCompte[c] = 0; });
|
|
let montantHt = 0, montantTtc = 0, ecart = 0;
|
|
for (const r of this.filteredRows) {
|
|
montantHt += r.montant_ht || 0;
|
|
montantTtc += r.montant_ttc || 0;
|
|
ecart += r.ecart || 0;
|
|
for (const c of this.allComptes) {
|
|
if (r.parCompte[c] !== undefined) parCompte[c] += r.parCompte[c];
|
|
}
|
|
}
|
|
return { montant_ht: montantHt, montant_ttc: montantTtc, parCompte, ecart };
|
|
},
|
|
filteredRows() {
|
|
// Drill-down mode: an entrée and only the sorties linked to it,
|
|
// ignoring the other filters -- clicking its badge again clears it.
|
|
if (this.filterEntreeId) {
|
|
const entree = this.rows.find((r) => r.id === this.filterEntreeId);
|
|
const linked = this.sortiesByEntree.get(this.filterEntreeId) || [];
|
|
return entree ? [entree, ...linked] : linked;
|
|
}
|
|
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;
|
|
});
|
|
},
|
|
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, 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 });
|
|
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);
|
|
},
|
|
// 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;
|
|
},
|
|
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 { data, includedMap } = await fetchAllLignes();
|
|
this.rows = buildRows(data, includedMap);
|
|
} catch (err) {
|
|
this.error = err.message;
|
|
} finally {
|
|
if (showLoading) this.loading = false;
|
|
}
|
|
},
|
|
},
|
|
mounted() {
|
|
this.load();
|
|
jQuery(document).on('dialog:afterclose', () => this.load(false));
|
|
},
|
|
};
|
|
|
|
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);
|