Files
bachir 9234050a68 Ajoute un filtre par tag de signalement sur /lignes et /dashboard/compte
Même modèle multiselect/OR que les filtres Compte et Type déjà en place,
en complément (pas en remplacement) du simple on/off "Signalées
uniquement" du grand livre. Sur /dashboard/compte, la liste d'options se
limite aux tags réellement présents pour le compte sélectionné, et se
réinitialise au changement de compte.
2026-09-06 21:59:32 +02:00

547 lines
24 KiB
JavaScript

/**
* @file
* Dashboard par compte associé (freelance) : un compte à la fois, choisi
* dans un menu. L'objectif principal est de faire ressortir clairement les
* "versement freelance" pas (encore) compensés par une "entrée client" --
* les autres types de ligne (charge, achat, hébergement, sous-traitant...)
* ne rentrent pas dans ce rapprochement, seuls entrée/versement comptent
* ici (contrairement à /lignes, où tout type "liable" est concerné).
*
* Comme dashboard.js : pas de librairie de graphes, tout est fait en
* div/CSS (voir dashboard.css) -- un dépendance de plus pour une poignée de
* barres n'en vaut pas la peine.
*
* Le rapprochement entrée/versement reprend exactement l'algorithme déjà
* en place dans home.js (reconciliationByEntree) : un versement peut être
* lié à plusieurs entrées à la fois (paiement groupé), auquel cas son
* montant est réparti à parts égales entre elles. Dupliqué ici plutôt que
* factorisé -- home.js et dashboard.js sont déjà deux fichiers autonomes
* sans module partagé, donc c'est la convention existante du projet, pas
* une entorse.
*/
(function (Drupal, Vue) {
'use strict';
const API_BASE = '/jsonapi/node/ligne_comptable';
const EUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' });
const EUR_ROUND = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 });
const MONTHS_SHORT = ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.'];
// Same labels/colors as dashboard.js's "Répartition de l'activité par
// type" -- duplicated rather than shared, see this file's docblock.
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',
};
const TYPE_COLORS = {
entree: '#1a7f37',
charge: '#6b7280',
versement: '#d97a0a',
achat: '#3b6fe0',
hebergement: '#0e9182',
sous_traitant: '#c9312b',
salaire_stage: '#0891b2',
charges_local_pro: '#65a30d',
autre: '#9061f0',
};
function resolve(includedMap, ref) {
if (!ref) return null;
return includedMap.get(ref.type + ':' + ref.id) || null;
}
// Same shape as home.js's buildRows() -- only the two types this page
// cares about ever reach it (see fetchEntreesEtVersements()).
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 entreeLieeRefs = (rels.field_entree_liee && rels.field_entree_liee.data) || [];
const entreeLieeIds = entreeLieeRefs.map((ref) => ref.id);
const flagRefs = (rels.field_flag && rels.field_flag.data) || [];
const flags = flagRefs.map((ref) => resolve(includedMap, ref)).filter(Boolean).map((t) => t.attributes.name);
const parCompte = {};
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;
}
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,
parCompte,
entreeLieeIds,
flags,
hasFlag: flags.length > 0,
});
}
rows.sort((a, b) => (a.date || '').localeCompare(b.date || ''));
return rows;
}
// Every entrée/versement, whatever their date -- unlike /lignes there's
// no sliding window here: this page needs the *complete* picture to
// reconcile a compte's entrées against its versements (one can easily
// be paid out a year or more after the other), and entrée+versement
// alone is a small enough slice of the ~1500+ line ledger to fetch in
// one page load (the rest -- charge/achat/hébergement/sous-traitant/
// autre/ouverture -- is exactly what this page deliberately excludes).
async function fetchEntreesEtVersements() {
const params = new URLSearchParams();
params.set('include', 'field_repartition,field_repartition.field_compte,field_client,field_entree_liee,field_flag');
params.set('filter[typeFilter][condition][path]', 'field_type_ligne');
params.set('filter[typeFilter][condition][operator]', 'IN');
params.append('filter[typeFilter][condition][value][]', 'entree');
params.append('filter[typeFilter][condition][value][]', 'versement');
params.set('page[limit]', '50');
params.set('sort', 'field_date_ligne,drupal_internal__nid');
let url = API_BASE + '?' + params.toString();
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;
}
const seen = new Set();
const dedup = allData.filter((n) => (seen.has(n.id) ? false : (seen.add(n.id), true)));
return buildRows(dedup, includedMap);
}
// page[limit]=200 would be silently clamped to core's hard cap of 50 --
// same reasoning as fetchClientNames() in home.js -- but with only 8
// comptes, one page always covers all of them; the pagination loop is
// kept anyway so this doesn't silently break if the vocabulary grows.
async function fetchComptes() {
let url = '/jsonapi/taxonomy_term/compte?sort=weight,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;
}
// Reuses the existing whole-ledger aggregate endpoint (plain SQL, all
// comptes/types/années at once) for the numbers that must reflect the
// *true* accounting balance -- solde par compte -- rather than
// recomputing a partial one from just entrée+versement rows, which
// would silently ignore that compte's charges/achats/etc.
async function fetchDashboardStats() {
const res = await fetch('/dashboard/api/stats', { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error('/dashboard/api/stats a répondu ' + res.status);
return res.json();
}
function readHashCompte() {
const params = new URLSearchParams(location.hash.replace(/^#/, ''));
return params.get('compte') || '';
}
function writeHashCompte(compte) {
const params = new URLSearchParams();
if (compte) params.set('compte', compte);
const hash = params.toString();
history.replaceState(null, '', location.pathname + location.search + (hash ? '#' + hash : ''));
}
// Vertical bar(s) per year, diverging from a zero baseline -- shared by
// "Évolution du solde" (one bar/year) and "Entrées vs versements par
// année" (two bars/year, side by side). A single flexible component
// instead of two near-identical ones.
const YearBarsChart = {
props: {
// [{ label, bars: [{ value, color, title }] }]
years: { type: Array, required: true },
formatValue: { type: Function, required: true },
},
computed: {
maxAbs() {
return Math.max(1, ...this.years.flatMap((y) => y.bars.map((b) => Math.abs(b.value))));
},
},
methods: {
barStyle(bar) {
const pct = (Math.abs(bar.value) / this.maxAbs) * 100;
return bar.value >= 0
? { bottom: '50%', height: pct / 2 + '%', background: bar.color }
: { top: '50%', height: pct / 2 + '%', background: bar.color };
},
},
template:
'<div class="figli-year-chart">' +
'<div class="figli-year-col" v-for="y in years" :key="y.label">' +
'<div class="figli-year-track">' +
'<div class="figli-year-bars">' +
'<div class="figli-year-bar-slot" v-for="(bar, i) in y.bars" :key="i">' +
'<div class="figli-year-bar" :style="barStyle(bar)" :title="bar.title + \' : \' + formatValue(bar.value)"></div>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="figli-year-label">{{ y.label }}</div>' +
'</div>' +
'</div>',
};
// Same shape as dashboard.js's HBarChart, colorFor included (used by
// the type-breakdown chart; Top clients below just omits it and gets
// the plain positive-green default).
const HBarChart = {
props: {
items: { type: Array, required: true },
formatValue: { type: Function, required: true },
colorFor: { type: Function, default: null },
// Narrower label/value columns, smaller text -- for the per-année
// small-multiples grids, where a full-width chart wouldn't fit in
// a grid card.
compact: { type: Boolean, default: false },
},
computed: {
maxAbs() {
return Math.max(1, ...this.items.map((i) => Math.abs(i.value)));
},
},
methods: {
fillColor(item) {
return this.colorFor ? this.colorFor(item) : 'var(--figli-positive)';
},
},
template:
'<div class="figli-hbar-chart" :class="{\'is-compact\': compact}">' +
'<div class="figli-hbar-row" v-for="item in items" :key="item.label">' +
'<div class="figli-hbar-label" :title="item.label">{{ item.label }}</div>' +
'<div class="figli-hbar-track">' +
'<div class="figli-hbar-fill" :style="{left: 0, width: (Math.abs(item.value) / maxAbs * 100) + \'%\', background: fillColor(item)}"></div>' +
'</div>' +
'<div class="figli-hbar-value">{{ formatValue(item.value) }}</div>' +
'</div>' +
'</div>',
};
const App = {
components: { YearBarsChart, HBarChart },
data() {
return {
loading: true,
error: null,
allRows: [],
comptes: [],
stats: null,
selectedCompte: '',
// Filtre "Lignes signalées" -- même modèle multi-valeurs/OR que
// sur /lignes. Propre à ce compte (voir selectCompte()).
flagFilter: [],
};
},
computed: {
sortiesByEntree() {
const map = new Map();
for (const r of this.allRows) {
for (const entreeId of r.entreeLieeIds) {
if (!map.has(entreeId)) map.set(entreeId, []);
map.get(entreeId).push(r);
}
}
return map;
},
// Same algorithm as home.js's reconciliationByEntree: per entrée,
// per compte, résidu = part de l'entrée pour ce compte + part des
// versements liés pour ce compte (montants négatifs), un versement
// lié à plusieurs entrées voyant son montant réparti à parts égales
// entre elles.
reconciliationByEntree() {
const map = new Map();
for (const entreeRow of this.allRows) {
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)]);
const parCompteResidual = {};
for (const c of comptes) {
parCompteResidual[c] = Math.round(((entreeRow.parCompte[c] || 0) + (versementsParCompte[c] || 0)) * 100) / 100;
}
map.set(entreeRow.id, { parCompteResidual });
}
return map;
},
entreesDuCompte() {
if (!this.selectedCompte) return [];
return this.allRows.filter((r) => r.type === 'entree' && r.parCompte[this.selectedCompte] !== undefined);
},
// Entrées dont la part du compte sélectionné n'est pas (entièrement)
// versée -- résidu positif = encore dû, négatif = sur-versé. C'est
// le coeur de la page : ce que le collectif doit encore à ce
// compte associé, entrée par entrée.
resteAVerserRows() {
return this.entreesDuCompte
.map((entree) => {
const recon = this.reconciliationByEntree.get(entree.id);
const residual = recon ? (recon.parCompteResidual[this.selectedCompte] ?? entree.parCompte[this.selectedCompte]) : entree.parCompte[this.selectedCompte];
return {
entree,
montantAttribue: Math.round(entree.parCompte[this.selectedCompte] * 100) / 100,
residual,
dejaVerse: Math.round((entree.parCompte[this.selectedCompte] - residual) * 100) / 100,
};
})
.filter((r) => Math.abs(r.residual) > 0.01)
.sort((a, b) => (a.entree.date || '').localeCompare(b.entree.date || ''));
},
resteAVerserPositif() {
return this.resteAVerserRows.filter((r) => r.residual > 0.01);
},
surVerseRows() {
return this.resteAVerserRows.filter((r) => r.residual < -0.01);
},
// Versements de ce compte qui ne pointent vers aucune entrée du
// tout -- ni "reste à verser" ni "sur-versé" ne les couvre (ces
// deux listes ne regardent que les *entrées*), donc sans ça un
// versement orphelin resterait invisible alors que c'est justement
// le genre d'anomalie que cette page doit faire ressortir.
versementsNonLies() {
if (!this.selectedCompte) return [];
return this.allRows
.filter((r) => r.type === 'versement' && r.parCompte[this.selectedCompte] !== undefined && r.entreeLieeIds.length === 0)
.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
},
// Toute ligne entrée/versement de ce compte portant un signalement
// (voir /lignes), qu'elle apparaisse déjà dans une des listes
// ci-dessus ou non -- une ligne entièrement soldée peut quand même
// porter un problème sans rapport avec le montant (ex. "client
// injoignable"), auquel cas aucune des trois listes ci-dessus ne
// la montrerait autrement.
lignesSignalees() {
if (!this.selectedCompte) return [];
return this.allRows
.filter((r) => r.hasFlag && r.parCompte[this.selectedCompte] !== undefined)
.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
},
// Tags réellement présents parmi les lignes signalées de ce compte
// -- dérivé de lignesSignalees (pas de lignesSignaleesFiltrees), pour
// que la liste d'options du filtre reste stable même une fois un tag
// sélectionné (sinon les autres tags disparaîtraient du menu dès
// qu'on en coche un).
flagsDisponibles() {
return Array.from(new Set(this.lignesSignalees.flatMap((r) => r.flags))).sort();
},
// Même modèle "plusieurs valeurs, sémantique OR" que le filtre Type
// sur /lignes -- une ligne ressort si elle porte au moins un des
// tags cochés.
lignesSignaleesFiltrees() {
if (!this.flagFilter.length) return this.lignesSignalees;
return this.lignesSignalees.filter((r) => this.flagFilter.some((f) => r.flags.includes(f)));
},
// Solde net des lignes signalées (filtrées) elles-mêmes (entrées
// reçues moins versements sortis, pour ce compte) -- pas un total
// "reste dû" comme totalResteAVerser ci-dessous, juste la somme des
// montants affichés dans le tableau, pour avoir une idée de
// l'ampleur de ce qui est signalé (ou de ce sous-ensemble de tags).
totalLignesSignalees() {
return Math.round(this.lignesSignaleesFiltrees.reduce((sum, r) => sum + r.parCompte[this.selectedCompte], 0) * 100) / 100;
},
totalResteAVerser() {
return Math.round(this.resteAVerserPositif.reduce((sum, r) => sum + r.residual, 0) * 100) / 100;
},
totalSurVerse() {
return Math.round(this.surVerseRows.reduce((sum, r) => sum - r.residual, 0) * 100) / 100;
},
totalNonLies() {
return Math.round(this.versementsNonLies.reduce((sum, r) => sum - (r.parCompte[this.selectedCompte] || 0), 0) * 100) / 100;
},
soldeActuel() {
if (!this.stats || !this.selectedCompte) return 0;
return this.stats.solde_par_compte[this.selectedCompte] || 0;
},
// Une seule barre/année, verte au-dessus de zéro / rouge en
// dessous -- solde de clôture de ce compte, année par année (même
// source que /dashboard, filtrée à ce seul compte).
evolutionSoldeYears() {
if (!this.stats || !this.selectedCompte) return [];
return this.stats.annees.map((y) => {
const v = this.stats.solde_par_compte_par_annee[y] ? this.stats.solde_par_compte_par_annee[y][this.selectedCompte] : undefined;
const value = v !== undefined ? v : 0;
return {
label: y,
bars: [{ value, color: value >= 0 ? 'var(--figli-positive)' : 'var(--figli-error)', title: 'Solde ' + y }],
};
});
},
// Deux barres/année : entrées attribuées à ce compte (vert, vers le
// haut) et versements de ce compte (rouge, déjà négatifs -- vers le
// bas) -- répond visuellement, année par année, à la question
// centrale de cette page.
entreeVsVersementYears() {
if (!this.stats || !this.selectedCompte) return [];
const parAnnee = new Map();
for (const y of this.stats.annees) parAnnee.set(y, { entree: 0, versement: 0 });
for (const r of this.allRows) {
const montant = r.parCompte[this.selectedCompte];
if (montant === undefined || !r.date) continue;
const y = r.date.slice(0, 4);
if (!parAnnee.has(y)) continue;
const bucket = parAnnee.get(y);
if (r.type === 'entree') bucket.entree += montant;
else bucket.versement += montant;
}
return this.stats.annees.map((y) => {
const b = parAnnee.get(y);
return {
label: y,
bars: [
{ value: Math.round(b.entree * 100) / 100, color: 'var(--figli-positive)', title: 'Entrées ' + y },
{ value: Math.round(b.versement * 100) / 100, color: 'var(--figli-error)', title: 'Versements ' + y },
],
};
});
},
topClientsItems() {
const parClient = new Map();
for (const r of this.entreesDuCompte) {
const client = r.client || '(sans client)';
parClient.set(client, (parClient.get(client) || 0) + r.parCompte[this.selectedCompte]);
}
return Array.from(parClient.entries())
.map(([label, value]) => ({ label, value: Math.round(value * 100) / 100 }))
.sort((a, b) => b.value - a.value)
.slice(0, 10);
},
// Same chart as /dashboard's "Répartition de l'activité par type",
// filtered to this compte -- unlike the reconciliation tables above
// (deliberately entrée/versement only, see this file's docblock),
// this comes straight from /dashboard/api/stats's per-compte
// breakdown, so it covers every type touching this compte's
// répartition (charge, achat, hébergement...), matching what the
// general dashboard shows for the whole ledger.
typeItems() {
if (!this.stats || !this.selectedCompte) return [];
const parType = this.stats.total_par_type_par_compte[this.selectedCompte] || {};
return Object.entries(parType)
.map(([type, value]) => ({ label: TYPE_LABELS[type] || type, value, type }))
.sort((a, b) => b.value - a.value);
},
// Small multiples, one per year -- same source as typeItems() above
// (total_par_type_par_compte_par_annee is the same répartition-level
// SQL query, just also grouped by année, no extra request).
typeItemsParAnnee() {
if (!this.stats || !this.selectedCompte) return [];
return this.stats.annees.map((annee) => {
const parType = (this.stats.total_par_type_par_compte_par_annee[annee] || {})[this.selectedCompte] || {};
const items = Object.entries(parType)
.map(([type, value]) => ({ label: TYPE_LABELS[type] || type, value, type }))
.sort((a, b) => b.value - a.value);
return { annee, items };
}).filter((y) => y.items.length);
},
// Top 5 (not 10 like the all-time chart, above) -- one per year
// keeps the small-multiples grid readable.
topClientsParAnnee() {
if (!this.selectedCompte) return [];
return this.stats.annees.map((annee) => {
const parClient = new Map();
for (const r of this.entreesDuCompte) {
if ((r.date || '').slice(0, 4) !== annee) continue;
const client = r.client || '(sans client)';
parClient.set(client, (parClient.get(client) || 0) + r.parCompte[this.selectedCompte]);
}
const items = Array.from(parClient.entries())
.map(([label, value]) => ({ label, value: Math.round(value * 100) / 100 }))
.sort((a, b) => b.value - a.value)
.slice(0, 5);
return { annee, items };
}).filter((y) => y.items.length);
},
},
methods: {
formatEur(v) {
return v === null || v === undefined ? '' : EUR.format(v);
},
formatEurRound(v) {
return EUR_ROUND.format(v);
},
typeColor(item) {
return TYPE_COLORS[item.type] || '#6b7280';
},
formatDate(d) {
if (!d) return '';
const parts = d.split('-');
return parts[2] + ' ' + MONTHS_SHORT[parseInt(parts[1], 10) - 1] + ' ' + parts[0];
},
// Ouvre le grand livre déjà filtré sur ce client -- pour aller voir
// le détail des lignes plutôt que de dupliquer une vue détaillée
// ici.
ligneHref(client) {
return '/lignes#client=' + encodeURIComponent(client) + '&type=entree,versement';
},
selectCompte(compte) {
this.selectedCompte = compte;
// A tag selected for one compte may not even exist for the next
// one -- flagsDisponibles() would just drop it from the visible
// options while leaving it silently active in flagFilter.
this.flagFilter = [];
writeHashCompte(compte);
},
async load() {
this.loading = true;
this.error = null;
try {
const [allRows, comptes, stats] = await Promise.all([
fetchEntreesEtVersements(),
fetchComptes(),
fetchDashboardStats(),
]);
this.allRows = allRows;
this.comptes = comptes;
this.stats = stats;
const fromHash = readHashCompte();
this.selectedCompte = fromHash && comptes.includes(fromHash) ? fromHash : (comptes[0] || '');
} catch (err) {
this.error = err.message;
} finally {
this.loading = false;
}
},
},
mounted() {
this.load();
},
};
Drupal.behaviors.figliComptaDashboardCompte = {
attach(context) {
const root = context.querySelector ? context.querySelector('#figli-dashboard-app') : null;
if (root && !root.dataset.figliInitialized) {
root.dataset.figliInitialized = '1';
Vue.createApp(App).mount(root);
}
},
};
})(Drupal, Vue);