Initial Drupal site: content model + validation + Vue dashboard
- Content type "Ligne comptable" with Paragraphs "Répartition" (Compte + Montant) - Taxonomies: Compte (9 comptes) and Client (unified client list) - hook_node_presave + form validate: sum(répartition) must equal montant HT - /dashboard route (progressive decoupling): Vue 3 app fetching JSON:API, computing solde par compte / par client client-side - "Ajouter une ligne" opens the real Drupal node form in an AJAX modal - Gin as default + admin theme - 9 opening-balance lines seeded from suivi_compta_SASFigli2026_v2.ods Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* @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 does all the aggregation (solde par compte, solde par
|
||||
* client) client-side.
|
||||
*/
|
||||
(function (Drupal, Vue, jQuery) {
|
||||
'use strict';
|
||||
|
||||
const API_BASE = '/jsonapi/node/ligne_comptable';
|
||||
const EUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' });
|
||||
|
||||
async function fetchAllLignes() {
|
||||
let url = API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client&page[limit]=50';
|
||||
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 addTo(map, key, montant) {
|
||||
if (!map.has(key)) map.set(key, { entrees: 0, sorties: 0 });
|
||||
const row = map.get(key);
|
||||
if (montant >= 0) row.entrees += montant;
|
||||
else row.sorties += montant;
|
||||
}
|
||||
|
||||
function computeAggregations(data, includedMap) {
|
||||
const parComptes = new Map();
|
||||
const parClients = new Map();
|
||||
|
||||
for (const node of data) {
|
||||
const rels = node.relationships || {};
|
||||
const clientTerm = resolve(includedMap, rels.field_client && rels.field_client.data);
|
||||
const clientName = clientTerm ? clientTerm.attributes.name : '(sans client)';
|
||||
|
||||
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)';
|
||||
|
||||
addTo(parComptes, compteName, montant);
|
||||
addTo(parClients, clientName, montant);
|
||||
}
|
||||
}
|
||||
return { parComptes, parClients };
|
||||
}
|
||||
|
||||
function mapToRows(map) {
|
||||
return Array.from(map.entries())
|
||||
.map(([name, v]) => ({ name, entrees: v.entrees, sorties: v.sorties, solde: v.entrees + v.sorties }))
|
||||
.sort((a, b) => a.solde - b.solde);
|
||||
}
|
||||
|
||||
function totalsOf(rows) {
|
||||
return rows.reduce(
|
||||
(acc, r) => ({ entrees: acc.entrees + r.entrees, sorties: acc.sorties + r.sorties, solde: acc.solde + r.solde }),
|
||||
{ entrees: 0, sorties: 0, solde: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
const App = {
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
error: null,
|
||||
tables: [],
|
||||
lineCount: 0,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
formatEur(v) {
|
||||
return EUR.format(v);
|
||||
},
|
||||
rowClass(solde) {
|
||||
if (solde > 0.5) return 'positive';
|
||||
if (solde < -0.5) return 'negative';
|
||||
return '';
|
||||
},
|
||||
openAddForm() {
|
||||
Drupal.ajax({
|
||||
url: '/node/add/ligne_comptable',
|
||||
dialogType: 'modal',
|
||||
dialog: { width: 700, title: 'Ajouter une ligne comptable' },
|
||||
progress: { type: 'throbber' },
|
||||
}).execute();
|
||||
},
|
||||
async load() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const { data, includedMap } = await fetchAllLignes();
|
||||
this.lineCount = data.length;
|
||||
const { parComptes, parClients } = computeAggregations(data, includedMap);
|
||||
const comptesRows = mapToRows(parComptes);
|
||||
const clientsRows = mapToRows(parClients);
|
||||
this.tables = [
|
||||
{
|
||||
title: 'Solde par compte',
|
||||
note: this.lineCount + ' lignes comptables chargées.',
|
||||
rows: comptesRows,
|
||||
totals: totalsOf(comptesRows),
|
||||
},
|
||||
{
|
||||
title: 'Solde par client',
|
||||
note: 'Entrées créditées par client vs. montants sortis (versements, achats, charges) sur les lignes rattachées à ce client.',
|
||||
rows: clientsRows,
|
||||
totals: totalsOf(clientsRows),
|
||||
},
|
||||
];
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.load();
|
||||
// Drupal's dialog system fires this on <body> (via jQuery) whenever a
|
||||
// modal closes -- refresh after "Ajouter une ligne" without a full
|
||||
// page reload.
|
||||
jQuery(document).on('dialog:afterclose', () => this.load());
|
||||
},
|
||||
};
|
||||
|
||||
Drupal.behaviors.figliComptaDashboard = {
|
||||
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);
|
||||
// Vue's mount() replaces the DOM node Drupal's own behaviors (like
|
||||
// use-ajax on the "Ajouter une ligne" link) already saw at page
|
||||
// load -- reattach so the modal link keeps working.
|
||||
Drupal.attachBehaviors(root);
|
||||
}
|
||||
},
|
||||
};
|
||||
})(Drupal, Vue, jQuery);
|
||||
Reference in New Issue
Block a user