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:
2026-09-03 22:32:54 +02:00
co-authored by Claude Sonnet 5
commit ca800be21a
218 changed files with 21168 additions and 0 deletions
@@ -0,0 +1,56 @@
#figli-dashboard-app {
font-family: var(--gin-font-family, Inter, -apple-system, sans-serif);
max-width: 1200px;
margin: 1rem 0;
}
#figli-dashboard-app h2 {
font-size: 1.25rem;
font-weight: 600;
margin: 2rem 0 0.75rem;
}
#figli-dashboard-app .figli-note {
color: var(--gin-color-text-light, #6b7280);
font-size: 0.875rem;
margin-bottom: 1rem;
}
#figli-dashboard-app table {
width: 100%;
border-collapse: collapse;
background: var(--gin-bg-layer2, #fff);
border: 1px solid var(--gin-border-color, #dcdee2);
border-radius: 6px;
overflow: hidden;
}
#figli-dashboard-app th,
#figli-dashboard-app td {
text-align: left;
padding: 0.5rem 0.85rem;
border-bottom: 1px solid var(--gin-border-color, #eceef1);
}
#figli-dashboard-app th {
background: var(--gin-bg-layer, #f5f6f8);
font-weight: 600;
font-size: 0.85rem;
}
#figli-dashboard-app td.amount {
text-align: right;
font-variant-numeric: tabular-nums;
}
#figli-dashboard-app tr.positive td.amount { color: #1a7f37; }
#figli-dashboard-app tr.negative td.amount { color: #c9312b; }
#figli-dashboard-app tr.total td { font-weight: 700; border-top: 2px solid var(--gin-border-color, #333); }
#figli-dashboard-app .figli-error {
background: #fde8e8;
border: 1px solid #f4a3a3;
color: #7a1a1a;
padding: 0.75rem 1rem;
border-radius: 6px;
}
@@ -0,0 +1,11 @@
name: 'Figli Compta - Ledger'
type: module
description: 'Modèle de données du grand livre SAS Figures Libres : comptes, clients, lignes comptables, répartitions.'
package: 'Figli Compta'
core_version_requirement: ^10 || ^11
dependencies:
- drupal:taxonomy
- drupal:node
- drupal:field
- drupal:datetime
- paragraphs:paragraphs
@@ -0,0 +1,232 @@
<?php
/**
* @file
* Install functions for the Figli Compta Ledger module.
*
* Builds the data model discussed with the SAS: a "Compte" per associate
* (+ EXT./EXT.WEB/Provision EPAU), a unified "Client" list, and
* "Ligne comptable" nodes whose amount is split across "Répartition"
* paragraphs referencing a Compte. The presave validation in
* figli_compta_ledger.module enforces sum(répartition) == montant_ht,
* which is the single most common error found in the historical spreadsheets.
*/
use Drupal\taxonomy\Entity\Vocabulary;
use Drupal\taxonomy\Entity\Term;
use Drupal\node\Entity\NodeType;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\field\Entity\FieldConfig;
use Drupal\paragraphs\Entity\ParagraphsType;
use Drupal\Core\Entity\Entity\EntityFormDisplay;
use Drupal\Core\Entity\Entity\EntityViewDisplay;
/**
* The 9 "comptes" tracked historically (6 associates + EXT./EXT.WEB/Provision EPAU).
*/
function _figli_compta_ledger_comptes() {
return ['Sandrine', 'Maud', 'Ouidade', 'Chloé', 'Bachir', 'Valentin', 'EXT.', 'EXT.WEB', 'Provision EPAU'];
}
/**
* Starter set of unified clients (from the 2021-2026 audit).
*/
function _figli_compta_ledger_clients() {
return [
'EPAU / POPSU', 'COLLECTIF RIVAGE / OU ATTERRIR', 'REHA', 'MATHALLO',
'LE CAMPUS', 'LA MINE', 'CERAS', 'LE SHED', 'MATERIO', 'IRI',
'MAUD BOYER', 'ECHELLE 1:1', 'RORSCHACH', 'EDLP', 'SEMISE',
];
}
/**
* Implements hook_install().
*/
function figli_compta_ledger_install() {
_figli_compta_ledger_create_vocabulary('compte', 'Compte', _figli_compta_ledger_comptes());
_figli_compta_ledger_create_vocabulary('client', 'Client', _figli_compta_ledger_clients());
_figli_compta_ledger_create_paragraph_repartition();
_figli_compta_ledger_create_node_type_ligne_comptable();
}
function _figli_compta_ledger_create_vocabulary($vid, $name, array $terms) {
if (!Vocabulary::load($vid)) {
Vocabulary::create(['vid' => $vid, 'name' => $name])->save();
}
$storage = \Drupal::entityTypeManager()->getStorage('taxonomy_term');
foreach ($terms as $term_name) {
$existing = $storage->loadByProperties(['vid' => $vid, 'name' => $term_name]);
if (empty($existing)) {
Term::create(['vid' => $vid, 'name' => $term_name])->save();
}
}
}
/**
* Creates a field storage + field instance if they don't already exist.
*/
function _figli_field($entity_type, $bundle, $field_name, $label, $type, array $storage_settings = [], $required = FALSE, array $field_settings = []) {
if (!FieldStorageConfig::loadByName($entity_type, $field_name)) {
FieldStorageConfig::create([
'field_name' => $field_name,
'entity_type' => $entity_type,
'type' => $type,
'settings' => $storage_settings,
])->save();
}
if (!FieldConfig::loadByName($entity_type, $bundle, $field_name)) {
FieldConfig::create([
'field_name' => $field_name,
'entity_type' => $entity_type,
'bundle' => $bundle,
'label' => $label,
'required' => $required,
'settings' => $field_settings,
])->save();
}
}
function _figli_entity_ref_field($entity_type, $bundle, $field_name, $label, $target_type, $target_bundle, $required = FALSE) {
if (!FieldStorageConfig::loadByName($entity_type, $field_name)) {
FieldStorageConfig::create([
'field_name' => $field_name,
'entity_type' => $entity_type,
'type' => 'entity_reference',
'settings' => ['target_type' => $target_type],
])->save();
}
if (!FieldConfig::loadByName($entity_type, $bundle, $field_name)) {
FieldConfig::create([
'field_name' => $field_name,
'entity_type' => $entity_type,
'bundle' => $bundle,
'label' => $label,
'required' => $required,
'settings' => [
'handler' => 'default:' . $target_type,
'handler_settings' => ['target_bundles' => [$target_bundle => $target_bundle]],
],
])->save();
}
}
function _figli_paragraph_field($entity_type, $bundle, $field_name, $label, $paragraph_bundle) {
if (!FieldStorageConfig::loadByName($entity_type, $field_name)) {
FieldStorageConfig::create([
'field_name' => $field_name,
'entity_type' => $entity_type,
'type' => 'entity_reference_revisions',
'cardinality' => -1,
'settings' => ['target_type' => 'paragraph'],
])->save();
}
if (!FieldConfig::loadByName($entity_type, $bundle, $field_name)) {
FieldConfig::create([
'field_name' => $field_name,
'entity_type' => $entity_type,
'bundle' => $bundle,
'label' => $label,
'required' => TRUE,
'settings' => [
'handler' => 'default:paragraph',
'handler_settings' => ['target_bundles' => [$paragraph_bundle => $paragraph_bundle]],
],
])->save();
}
}
function _figli_compta_ledger_create_paragraph_repartition() {
if (!ParagraphsType::load('repartition')) {
ParagraphsType::create(['id' => 'repartition', 'label' => 'Répartition'])->save();
}
_figli_entity_ref_field('paragraph', 'repartition', 'field_compte', 'Compte', 'taxonomy_term', 'compte', TRUE);
_figli_field('paragraph', 'repartition', 'field_montant', 'Montant (€)', 'decimal', ['precision' => 12, 'scale' => 2], TRUE);
if (!EntityFormDisplay::load('paragraph.repartition.default')) {
EntityFormDisplay::create([
'targetEntityType' => 'paragraph',
'bundle' => 'repartition',
'mode' => 'default',
'status' => TRUE,
])
->setComponent('field_compte', ['type' => 'entity_reference_autocomplete', 'weight' => 0])
->setComponent('field_montant', ['type' => 'number', 'weight' => 1])
->save();
}
if (!EntityViewDisplay::load('paragraph.repartition.default')) {
EntityViewDisplay::create([
'targetEntityType' => 'paragraph',
'bundle' => 'repartition',
'mode' => 'default',
'status' => TRUE,
])
->setComponent('field_compte', ['type' => 'entity_reference_label', 'weight' => 0])
->setComponent('field_montant', ['type' => 'number_decimal', 'weight' => 1])
->save();
}
}
function _figli_compta_ledger_create_node_type_ligne_comptable() {
if (!NodeType::load('ligne_comptable')) {
NodeType::create([
'type' => 'ligne_comptable',
'name' => 'Ligne comptable',
'description' => "Une entrée, sortie, versement ou charge du grand livre.",
])->save();
}
_figli_field('node', 'ligne_comptable', 'field_date_ligne', 'Date', 'datetime', ['datetime_type' => 'date'], TRUE);
_figli_field('node', 'ligne_comptable', 'field_type_ligne', 'Type de ligne', 'list_string', [
'allowed_values' => [
'entree' => 'Entrée client',
'charge' => 'Charge structurelle SAS',
'versement' => 'Versement freelance',
'achat' => 'Achat client (pass-through)',
'ouverture' => "Ligne d'ouverture",
],
], TRUE);
_figli_entity_ref_field('node', 'ligne_comptable', 'field_client', 'Client', 'taxonomy_term', 'client');
_figli_field('node', 'ligne_comptable', 'field_montant_ht', 'Montant HT (€)', 'decimal', ['precision' => 12, 'scale' => 2], TRUE);
_figli_field('node', 'ligne_comptable', 'field_montant_ttc', 'Montant TTC (€)', 'decimal', ['precision' => 12, 'scale' => 2]);
_figli_field('node', 'ligne_comptable', 'field_notes', 'Notes / détail', 'string_long');
_figli_paragraph_field('node', 'ligne_comptable', 'field_repartition', 'Répartition', 'repartition');
if (!EntityFormDisplay::load('node.ligne_comptable.default')) {
EntityFormDisplay::create([
'targetEntityType' => 'node',
'bundle' => 'ligne_comptable',
'mode' => 'default',
'status' => TRUE,
])
->setComponent('field_date_ligne', ['type' => 'datetime_default', 'weight' => 0])
->setComponent('field_type_ligne', ['type' => 'options_select', 'weight' => 1])
->setComponent('field_client', ['type' => 'entity_reference_autocomplete', 'weight' => 2])
->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 3])
->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 4])
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 5, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']])
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 6])
->save();
}
if (!EntityViewDisplay::load('node.ligne_comptable.default')) {
EntityViewDisplay::create([
'targetEntityType' => 'node',
'bundle' => 'ligne_comptable',
'mode' => 'default',
'status' => TRUE,
])
->setComponent('field_date_ligne', ['type' => 'datetime_default', 'weight' => 0])
->setComponent('field_type_ligne', ['type' => 'list_default', 'weight' => 1])
->setComponent('field_client', ['type' => 'entity_reference_label', 'weight' => 2])
->setComponent('field_montant_ht', ['type' => 'number_decimal', 'weight' => 3])
->setComponent('field_montant_ttc', ['type' => 'number_decimal', 'weight' => 4])
->setComponent('field_repartition', ['type' => 'entity_reference_revisions_entity_view', 'weight' => 5])
->setComponent('field_notes', ['type' => 'basic_string', 'weight' => 6])
->save();
}
}
@@ -0,0 +1,16 @@
vue:
js:
js/vendor/vue.global.prod.js: {}
version: '3.5.42'
dashboard:
js:
js/dashboard.js: {}
css:
theme:
css/dashboard.css: {}
dependencies:
- core/drupal
- core/drupal.dialog.ajax
- core/jquery
- figli_compta_ledger/vue
@@ -0,0 +1,7 @@
figli_compta_ledger.dashboard:
title: 'Tableau de bord'
description: 'Soldes par personne et par client'
route_name: figli_compta_ledger.dashboard
menu_name: admin
parent: system.admin
weight: -10
@@ -0,0 +1,148 @@
<?php
/**
* @file
* Enforces the one invariant that explained most of the errors found in the
* historical spreadsheets: sum(répartition.montant) must equal montant_ht.
*/
use Drupal\node\NodeInterface;
use Drupal\Core\Entity\EntityStorageException;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\CloseModalDialogCommand;
use Drupal\Core\Ajax\ReplaceCommand;
/**
* Implements hook_form_alter().
*
* Targets both node_ligne_comptable_form (add) and
* node_ligne_comptable_edit_form (edit) -- the entity form base_form_id for
* nodes is the generic "node_form", too broad to alter just this bundle, so
* we check the concrete $form_id instead. Two things: (1) a friendly inline
* #validate error for the sum(répartition) == montant_ht invariant, so a
* mismatch shows a normal form error instead of the hard
* EntityStorageException below reaching the user; (2) when the form is
* opened in the dashboard's AJAX modal, make the submit button AJAX-aware
* so it closes the modal (and the dashboard picks up "dialog:afterclose" to
* refresh) instead of doing a full-page redirect.
*/
function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state, $form_id) {
if (!in_array($form_id, ['node_ligne_comptable_form', 'node_ligne_comptable_edit_form'], TRUE)) {
return;
}
$form['#validate'][] = 'figli_compta_ledger_validate_repartition';
$request = \Drupal::request();
$wrapper_formats = ['drupal_ajax', 'drupal_modal', 'drupal_dialog'];
$is_ajax_modal = in_array($request->query->get('_wrapper_format'), $wrapper_formats, TRUE)
|| in_array($request->request->get('_wrapper_format'), $wrapper_formats, TRUE);
if ($is_ajax_modal || $request->headers->get('X-Requested-With') === 'XMLHttpRequest') {
$form['actions']['submit']['#ajax'] = [
'callback' => 'figli_compta_ledger_node_form_ajax_submit',
];
}
}
/**
* Form #validate callback: sum(répartition) must equal montant_ht.
*/
function figli_compta_ledger_validate_repartition(array &$form, FormStateInterface $form_state) {
$montant_ht_raw = $form_state->getValue(['field_montant_ht', 0, 'value']);
if ($montant_ht_raw === NULL || $montant_ht_raw === '') {
return;
}
$montant_ht = (float) $montant_ht_raw;
$somme = 0.0;
$repartition = $form_state->getValue('field_repartition') ?: [];
foreach ($repartition as $delta => $item) {
if (!is_numeric($delta) || !is_array($item)) {
continue;
}
$montant = $item['subform']['field_montant'][0]['value']
?? $item['field_montant'][0]['value']
?? NULL;
if ($montant !== NULL && $montant !== '') {
$somme += (float) $montant;
}
}
$ecart = round($montant_ht - $somme, 2);
if (abs($ecart) > 0.01) {
$form_state->setErrorByName('field_repartition', t(
'Répartition incohérente : la somme des comptes (@somme €) ne correspond pas au montant HT (@ht €). Écart : @ecart €.',
['@somme' => number_format($somme, 2), '@ht' => number_format($montant_ht, 2), '@ecart' => number_format($ecart, 2)]
));
}
}
/**
* #ajax callback for the node form submit button: close the modal on
* success, or re-render the form (with errors) in place on failure.
*/
function figli_compta_ledger_node_form_ajax_submit(array $form, FormStateInterface $form_state) {
$response = new AjaxResponse();
if ($form_state->getErrors()) {
unset($form['#prefix'], $form['#suffix']);
$response->addCommand(new ReplaceCommand('#' . $form['#id'], $form));
return $response;
}
$response->addCommand(new CloseModalDialogCommand());
return $response;
}
/**
* Implements hook_ENTITY_TYPE_presave() for node.
*
* Last-resort safety net (also protects non-form paths like JSON:API POST
* or drush scripts) -- the form above should normally catch this first with
* a friendly inline message.
*/
function figli_compta_ledger_node_presave(NodeInterface $node) {
if ($node->bundle() !== 'ligne_comptable') {
return;
}
if (!$node->hasField('field_montant_ht') || !$node->hasField('field_repartition')) {
return;
}
$montant_ht = (float) $node->get('field_montant_ht')->value;
$somme = 0.0;
foreach ($node->get('field_repartition')->referencedEntities() as $paragraph) {
if ($paragraph->hasField('field_montant') && !$paragraph->get('field_montant')->isEmpty()) {
$somme += (float) $paragraph->get('field_montant')->value;
}
}
$ecart = round($montant_ht - $somme, 2);
if (abs($ecart) > 0.01) {
throw new EntityStorageException(sprintf(
"Répartition incohérente : la somme des comptes (%.2f €) ne correspond pas au montant HT (%.2f €). Écart : %.2f €. Corrigez la répartition avant d'enregistrer.",
$somme,
$montant_ht,
$ecart
));
}
}
/**
* Implements hook_theme().
*/
function figli_compta_ledger_theme($existing, $type, $theme, $path) {
return [
'figli_compta_dashboard' => [
'variables' => [],
'template' => 'figli-compta-dashboard',
],
];
}
/**
* Implements hook_help().
*/
function figli_compta_ledger_help($route_name, \Drupal\Core\Routing\RouteMatchInterface $route_match) {
if ($route_name === 'help.page.figli_compta_ledger') {
return '<p>' . t('Grand livre SAS Figures Libres : chaque "Ligne comptable" (entrée, charge, versement, achat) doit être répartie entre un ou plusieurs "Comptes" (les 6 associés + EXT./EXT.WEB/Provision EPAU) pour un total exactement égal au montant HT.') . '</p>';
}
}
@@ -0,0 +1,7 @@
figli_compta_ledger.dashboard:
path: '/dashboard'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::view'
_title: 'Tableau de bord - SAS Figures Libres'
requirements:
_permission: 'access content'
@@ -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);
File diff suppressed because one or more lines are too long
@@ -0,0 +1,23 @@
<?php
namespace Drupal\figli_compta_ledger\Controller;
use Drupal\Core\Controller\ControllerBase;
/**
* Renders the JS-driven ledger dashboard (progressive decoupling: Drupal
* serves the page shell + auth via the session cookie, dashboard.js fetches
* JSON:API and does all the aggregation client-side).
*/
class DashboardController extends ControllerBase {
public function view() {
return [
'#theme' => 'figli_compta_dashboard',
'#attached' => [
'library' => ['figli_compta_ledger/dashboard'],
],
];
}
}
@@ -0,0 +1,42 @@
{#
Dashboard shell: Drupal renders the page (nav, auth, permissions).
dashboard.js (a Vue 3 app) fetches JSON:API and renders everything below,
client-side. "Ajouter une ligne" opens the real Drupal node form in a
modal (core/drupal.dialog.ajax) -- no form logic duplicated in JS.
{% verbatim %} below: this is Vue template syntax, not Twig -- both use
{{ }}, so verbatim tells Twig to leave it alone and let Vue compile it
in the browser.
#}
{% verbatim %}
<div id="figli-dashboard-app">
<p>
<a href="/node/add/ligne_comptable" class="button button--primary" @click.prevent="openAddForm">+ Ajouter une ligne</a>
</p>
<p v-if="loading">Chargement des données…</p>
<p v-else-if="error" class="figli-error">Erreur de chargement du tableau de bord : {{ error }}</p>
<template v-else>
<section v-for="table in tables" :key="table.title">
<h2>{{ table.title }}</h2>
<p class="figli-note" v-if="table.note">{{ table.note }}</p>
<table>
<thead><tr><th></th><th>Entrées (+)</th><th>Sorties (-)</th><th>Solde</th></tr></thead>
<tbody>
<tr v-for="row in table.rows" :key="row.name" :class="rowClass(row.solde)">
<td>{{ row.name }}</td>
<td class="amount">{{ formatEur(row.entrees) }}</td>
<td class="amount">{{ formatEur(row.sorties) }}</td>
<td class="amount">{{ formatEur(row.solde) }}</td>
</tr>
<tr class="total">
<td>TOTAL</td>
<td class="amount">{{ formatEur(table.totals.entrees) }}</td>
<td class="amount">{{ formatEur(table.totals.sorties) }}</td>
<td class="amount">{{ formatEur(table.totals.solde) }}</td>
</tr>
</tbody>
</table>
</section>
</template>
</div>
{% endverbatim %}