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,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 %}
|
||||
Reference in New Issue
Block a user