Add spreadsheet-style home page, migrate full 2026 ledger
- New /lignes route (set as site front page): full line-by-line table of every ligne comptable, Vue app with filters (compte/client/type/année) and month/year grouping, columns matching the original spreadsheet (one per compte). Rows with répartition ≠ montant HT are visibly flagged (red row + écart column), not hidden or auto-corrected. - /dashboard now only holds the aggregate solde-par-compte/par-client view - Migrated all 199 real 2026 transaction lines + 9 opening balances (from REPORT CLOTURE 2025) via a drush import script, preserving raw source data (known répartition mismatches included) -- validated with a new state-flag bypass of the presave check, used only for historical import - Added "Autre" as an allowed field_type_ligne value for edge-case rows - Client taxonomy grew from 15 seeded terms to the full unified list Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* @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',
|
||||
autre: 'Autre',
|
||||
ouverture: 'Ouverture',
|
||||
};
|
||||
|
||||
async function fetchAllLignes() {
|
||||
let url = API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client&page[limit]=50&sort=field_date_ligne';
|
||||
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;
|
||||
}
|
||||
return { data: allData, 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 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,
|
||||
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,
|
||||
});
|
||||
}
|
||||
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', 'Provision EPAU'],
|
||||
filterCompte: '',
|
||||
filterClient: '',
|
||||
filterType: '',
|
||||
filterYear: '',
|
||||
groupBy: 'month',
|
||||
onlyErrors: false,
|
||||
};
|
||||
},
|
||||
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;
|
||||
},
|
||||
filteredRows() {
|
||||
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);
|
||||
},
|
||||
typeLabel(t) {
|
||||
return TYPE_LABELS[t] || t;
|
||||
},
|
||||
openAddForm() {
|
||||
Drupal.ajax({
|
||||
url: '/node/add/ligne_comptable',
|
||||
dialogType: 'modal',
|
||||
dialog: { width: 800, title: 'Ajouter une ligne comptable' },
|
||||
progress: { type: 'throbber' },
|
||||
}).execute();
|
||||
},
|
||||
async load() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const { data, includedMap } = await fetchAllLignes();
|
||||
this.rows = buildRows(data, includedMap);
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.load();
|
||||
jQuery(document).on('dialog:afterclose', () => this.load());
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
Reference in New Issue
Block a user