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
+42
View File
@@ -0,0 +1,42 @@
Modules extend your site functionality beyond Drupal core.
WHAT TO PLACE IN THIS DIRECTORY?
--------------------------------
Placing downloaded and custom modules in this directory separates downloaded and
custom modules from Drupal core's modules. This allows Drupal core to be updated
without overwriting these files.
DOWNLOAD ADDITIONAL MODULES
---------------------------
Contributed modules from the Drupal community may be downloaded at
https://www.drupal.org/project/project_module.
ORGANIZING MODULES IN THIS DIRECTORY
------------------------------------
You may create subdirectories in this directory, to organize your added modules,
without breaking the site. Some common subdirectories include "contrib" for
contributed modules, and "custom" for custom modules. Note that if you move a
module to a subdirectory after it has been enabled, you may need to clear the
Drupal cache so it can be found.
There are number of directories that are ignored when looking for modules. These
are 'src', 'lib', 'vendor', 'assets', 'css', 'files', 'images', 'js', 'misc',
'templates', 'includes', 'fixtures' and 'Drupal'.
MULTISITE CONFIGURATION
-----------------------
In multisite configurations, modules found in this directory are available to
all sites. You may also put modules in the sites/all/modules directory, and the
versions in sites/all/modules will take precedence over versions of the same
module that are here. Alternatively, the sites/your_site_name/modules directory
pattern may be used to restrict modules to a specific site instance.
MORE INFORMATION
----------------
Refer to the “Developing for Drupal” section of the README.md in the Drupal
root directory for further information on extending Drupal with custom modules.
@@ -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 %}