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:
@@ -26,6 +26,9 @@ settings:
|
|||||||
-
|
-
|
||||||
value: ouverture
|
value: ouverture
|
||||||
label: "Ligne d'ouverture"
|
label: "Ligne d'ouverture"
|
||||||
|
-
|
||||||
|
value: autre
|
||||||
|
label: Autre
|
||||||
allowed_values_function: ''
|
allowed_values_function: ''
|
||||||
module: options
|
module: options
|
||||||
locked: false
|
locked: false
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ slogan: ''
|
|||||||
page:
|
page:
|
||||||
403: ''
|
403: ''
|
||||||
404: ''
|
404: ''
|
||||||
front: /node
|
front: /lignes
|
||||||
admin_compact_mode: false
|
admin_compact_mode: false
|
||||||
weight_select_max: 100
|
weight_select_max: 100
|
||||||
default_langcode: en
|
default_langcode: en
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
#figli-home-app {
|
||||||
|
font-family: var(--gin-font-family, Inter, -apple-system, sans-serif);
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#figli-home-app .figli-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem 1.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--gin-bg-layer, #f5f6f8);
|
||||||
|
border: 1px solid var(--gin-border-color, #dcdee2);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#figli-home-app .figli-toolbar label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gin-color-text-light, #6b7280);
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#figli-home-app .figli-toolbar select {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#figli-home-app .figli-checkbox {
|
||||||
|
flex-direction: row !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#figli-home-app .figli-count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--gin-color-text-light, #6b7280);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#figli-home-app .figli-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--gin-border-color, #dcdee2);
|
||||||
|
border-radius: 6px;
|
||||||
|
max-height: 75vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#figli-home-app table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: var(--gin-bg-layer2, #fff);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#figli-home-app th,
|
||||||
|
#figli-home-app td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--gin-border-color, #eceef1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#figli-home-app thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--gin-bg-layer, #f5f6f8);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#figli-home-app td.amount,
|
||||||
|
#figli-home-app th.amount {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
#figli-home-app td.compte-col {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
#figli-home-app td.figli-libelle {
|
||||||
|
white-space: normal;
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#figli-home-app tr.figli-group-row td {
|
||||||
|
background: var(--gin-bg-layer, #eef0f3);
|
||||||
|
font-weight: 700;
|
||||||
|
position: sticky;
|
||||||
|
top: 2rem;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#figli-home-app tr.figli-error-row {
|
||||||
|
background: #fdf1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#figli-home-app td.figli-ecart {
|
||||||
|
color: #c9312b;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
#figli-home-app .figli-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
#figli-home-app .type-entree { background: #e2f5e9; color: #1a7f37; }
|
||||||
|
#figli-home-app .type-charge { background: #eceef1; color: #4b5563; }
|
||||||
|
#figli-home-app .type-versement { background: #fdeed6; color: #9a5b13; }
|
||||||
|
#figli-home-app .type-achat { background: #e6eefc; color: #1d4ed8; }
|
||||||
|
#figli-home-app .type-ouverture { background: #ede4fb; color: #6b21a8; }
|
||||||
|
#figli-home-app .type-autre { background: #f1f1f1; color: #555; }
|
||||||
|
|
||||||
|
#figli-home-app .figli-note {
|
||||||
|
color: var(--gin-color-text-light, #6b7280);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#figli-home-app .figli-error {
|
||||||
|
background: #fde8e8;
|
||||||
|
border: 1px solid #f4a3a3;
|
||||||
|
color: #7a1a1a;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
@@ -3,6 +3,18 @@ vue:
|
|||||||
js/vendor/vue.global.prod.js: {}
|
js/vendor/vue.global.prod.js: {}
|
||||||
version: '3.5.42'
|
version: '3.5.42'
|
||||||
|
|
||||||
|
home:
|
||||||
|
js:
|
||||||
|
js/home.js: {}
|
||||||
|
css:
|
||||||
|
theme:
|
||||||
|
css/home.css: {}
|
||||||
|
dependencies:
|
||||||
|
- core/drupal
|
||||||
|
- core/drupal.dialog.ajax
|
||||||
|
- core/jquery
|
||||||
|
- figli_compta_ledger/vue
|
||||||
|
|
||||||
dashboard:
|
dashboard:
|
||||||
js:
|
js:
|
||||||
js/dashboard.js: {}
|
js/dashboard.js: {}
|
||||||
@@ -11,6 +23,4 @@ dashboard:
|
|||||||
css/dashboard.css: {}
|
css/dashboard.css: {}
|
||||||
dependencies:
|
dependencies:
|
||||||
- core/drupal
|
- core/drupal
|
||||||
- core/drupal.dialog.ajax
|
|
||||||
- core/jquery
|
|
||||||
- figli_compta_ledger/vue
|
- figli_compta_ledger/vue
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
figli_compta_ledger.home:
|
||||||
|
title: 'Grand livre'
|
||||||
|
description: 'Toutes les lignes comptables, filtrables et regroupables'
|
||||||
|
route_name: figli_compta_ledger.home
|
||||||
|
menu_name: admin
|
||||||
|
parent: system.admin
|
||||||
|
weight: -11
|
||||||
|
|
||||||
figli_compta_ledger.dashboard:
|
figli_compta_ledger.dashboard:
|
||||||
title: 'Tableau de bord'
|
title: 'Tableau de bord'
|
||||||
description: 'Soldes par personne et par client'
|
description: 'Soldes par personne et par client'
|
||||||
|
|||||||
@@ -98,6 +98,13 @@ function figli_compta_ledger_node_form_ajax_submit(array $form, FormStateInterfa
|
|||||||
* Last-resort safety net (also protects non-form paths like JSON:API POST
|
* 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
|
* or drush scripts) -- the form above should normally catch this first with
|
||||||
* a friendly inline message.
|
* a friendly inline message.
|
||||||
|
*
|
||||||
|
* Historical imports (drush migration scripts) deliberately preserve the
|
||||||
|
* source spreadsheets' raw data, including known répartition mismatches --
|
||||||
|
* those get surfaced as visible inconsistencies in the dashboard instead of
|
||||||
|
* being silently fixed. Set the 'figli_compta_ledger.skip_validation' state
|
||||||
|
* flag around such a bulk import to bypass this check; new lines entered by
|
||||||
|
* associates through the form are never exempted.
|
||||||
*/
|
*/
|
||||||
function figli_compta_ledger_node_presave(NodeInterface $node) {
|
function figli_compta_ledger_node_presave(NodeInterface $node) {
|
||||||
if ($node->bundle() !== 'ligne_comptable') {
|
if ($node->bundle() !== 'ligne_comptable') {
|
||||||
@@ -106,6 +113,9 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
|
|||||||
if (!$node->hasField('field_montant_ht') || !$node->hasField('field_repartition')) {
|
if (!$node->hasField('field_montant_ht') || !$node->hasField('field_repartition')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (\Drupal::state()->get('figli_compta_ledger.skip_validation', FALSE)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$montant_ht = (float) $node->get('field_montant_ht')->value;
|
$montant_ht = (float) $node->get('field_montant_ht')->value;
|
||||||
$somme = 0.0;
|
$somme = 0.0;
|
||||||
@@ -131,6 +141,10 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
|
|||||||
*/
|
*/
|
||||||
function figli_compta_ledger_theme($existing, $type, $theme, $path) {
|
function figli_compta_ledger_theme($existing, $type, $theme, $path) {
|
||||||
return [
|
return [
|
||||||
|
'figli_compta_home' => [
|
||||||
|
'variables' => [],
|
||||||
|
'template' => 'figli-compta-home',
|
||||||
|
],
|
||||||
'figli_compta_dashboard' => [
|
'figli_compta_dashboard' => [
|
||||||
'variables' => [],
|
'variables' => [],
|
||||||
'template' => 'figli-compta-dashboard',
|
'template' => 'figli-compta-dashboard',
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
figli_compta_ledger.home:
|
||||||
|
path: '/lignes'
|
||||||
|
defaults:
|
||||||
|
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::home'
|
||||||
|
_title: 'Grand livre - SAS Figures Libres'
|
||||||
|
requirements:
|
||||||
|
_permission: 'access content'
|
||||||
|
|
||||||
figli_compta_ledger.dashboard:
|
figli_compta_ledger.dashboard:
|
||||||
path: '/dashboard'
|
path: '/dashboard'
|
||||||
defaults:
|
defaults:
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* @file
|
* @file
|
||||||
* Progressive decoupling: Drupal renders the page shell (nav, auth via
|
* Aggregate dashboard: solde par compte / solde par client, computed
|
||||||
* session cookie, the "Ajouter une ligne" modal form); this Vue app fetches
|
* client-side from JSON:API. The line-by-line spreadsheet view is the
|
||||||
* JSON:API and does all the aggregation (solde par compte, solde par
|
* site's home page (home.js), not this one.
|
||||||
* client) client-side.
|
|
||||||
*/
|
*/
|
||||||
(function (Drupal, Vue, jQuery) {
|
(function (Drupal, Vue) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const API_BASE = '/jsonapi/node/ligne_comptable';
|
const API_BASE = '/jsonapi/node/ligne_comptable';
|
||||||
@@ -15,12 +14,9 @@
|
|||||||
let url = API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client&page[limit]=50';
|
let url = API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client&page[limit]=50';
|
||||||
const allData = [];
|
const allData = [];
|
||||||
const includedMap = new Map();
|
const includedMap = new Map();
|
||||||
|
|
||||||
while (url) {
|
while (url) {
|
||||||
const res = await fetch(url, { headers: { Accept: 'application/vnd.api+json' } });
|
const res = await fetch(url, { headers: { Accept: 'application/vnd.api+json' } });
|
||||||
if (!res.ok) {
|
if (!res.ok) throw new Error('JSON:API a répondu ' + res.status);
|
||||||
throw new Error('JSON:API a répondu ' + res.status);
|
|
||||||
}
|
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
allData.push(...(json.data || []));
|
allData.push(...(json.data || []));
|
||||||
(json.included || []).forEach((item) => includedMap.set(item.type + ':' + item.id, item));
|
(json.included || []).forEach((item) => includedMap.set(item.type + ':' + item.id, item));
|
||||||
@@ -44,21 +40,17 @@
|
|||||||
function computeAggregations(data, includedMap) {
|
function computeAggregations(data, includedMap) {
|
||||||
const parComptes = new Map();
|
const parComptes = new Map();
|
||||||
const parClients = new Map();
|
const parClients = new Map();
|
||||||
|
|
||||||
for (const node of data) {
|
for (const node of data) {
|
||||||
const rels = node.relationships || {};
|
const rels = node.relationships || {};
|
||||||
const clientTerm = resolve(includedMap, rels.field_client && rels.field_client.data);
|
const clientTerm = resolve(includedMap, rels.field_client && rels.field_client.data);
|
||||||
const clientName = clientTerm ? clientTerm.attributes.name : '(sans client)';
|
const clientName = clientTerm ? clientTerm.attributes.name : '(sans client)';
|
||||||
|
|
||||||
const repartitionRefs = (rels.field_repartition && rels.field_repartition.data) || [];
|
const repartitionRefs = (rels.field_repartition && rels.field_repartition.data) || [];
|
||||||
for (const ref of repartitionRefs) {
|
for (const ref of repartitionRefs) {
|
||||||
const paragraph = resolve(includedMap, ref);
|
const paragraph = resolve(includedMap, ref);
|
||||||
if (!paragraph) continue;
|
if (!paragraph) continue;
|
||||||
const montant = parseFloat(paragraph.attributes.field_montant || 0);
|
const montant = parseFloat(paragraph.attributes.field_montant || 0);
|
||||||
|
|
||||||
const compteTerm = resolve(includedMap, paragraph.relationships && paragraph.relationships.field_compte && paragraph.relationships.field_compte.data);
|
const compteTerm = resolve(includedMap, paragraph.relationships && paragraph.relationships.field_compte && paragraph.relationships.field_compte.data);
|
||||||
const compteName = compteTerm ? compteTerm.attributes.name : '(compte inconnu)';
|
const compteName = compteTerm ? compteTerm.attributes.name : '(compte inconnu)';
|
||||||
|
|
||||||
addTo(parComptes, compteName, montant);
|
addTo(parComptes, compteName, montant);
|
||||||
addTo(parClients, clientName, montant);
|
addTo(parClients, clientName, montant);
|
||||||
}
|
}
|
||||||
@@ -81,12 +73,7 @@
|
|||||||
|
|
||||||
const App = {
|
const App = {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return { loading: true, error: null, tables: [], lineCount: 0 };
|
||||||
loading: true,
|
|
||||||
error: null,
|
|
||||||
tables: [],
|
|
||||||
lineCount: 0,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
formatEur(v) {
|
formatEur(v) {
|
||||||
@@ -97,14 +84,6 @@
|
|||||||
if (solde < -0.5) return 'negative';
|
if (solde < -0.5) return 'negative';
|
||||||
return '';
|
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() {
|
async load() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
@@ -115,18 +94,8 @@
|
|||||||
const comptesRows = mapToRows(parComptes);
|
const comptesRows = mapToRows(parComptes);
|
||||||
const clientsRows = mapToRows(parClients);
|
const clientsRows = mapToRows(parClients);
|
||||||
this.tables = [
|
this.tables = [
|
||||||
{
|
{ title: 'Solde par compte', note: this.lineCount + ' lignes comptables chargées.', rows: comptesRows, totals: totalsOf(comptesRows) },
|
||||||
title: 'Solde par compte',
|
{ 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) },
|
||||||
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) {
|
} catch (err) {
|
||||||
this.error = err.message;
|
this.error = err.message;
|
||||||
@@ -137,10 +106,6 @@
|
|||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.load();
|
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());
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -150,11 +115,7 @@
|
|||||||
if (root && !root.dataset.figliInitialized) {
|
if (root && !root.dataset.figliInitialized) {
|
||||||
root.dataset.figliInitialized = '1';
|
root.dataset.figliInitialized = '1';
|
||||||
Vue.createApp(App).mount(root);
|
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);
|
})(Drupal, Vue);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -5,12 +5,27 @@ namespace Drupal\figli_compta_ledger\Controller;
|
|||||||
use Drupal\Core\Controller\ControllerBase;
|
use Drupal\Core\Controller\ControllerBase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the JS-driven ledger dashboard (progressive decoupling: Drupal
|
* Renders the JS-driven pages (progressive decoupling: Drupal serves the
|
||||||
* serves the page shell + auth via the session cookie, dashboard.js fetches
|
* page shell + auth via the session cookie, the JS fetches JSON:API and
|
||||||
* JSON:API and does all the aggregation client-side).
|
* does all the aggregation/rendering client-side).
|
||||||
*/
|
*/
|
||||||
class DashboardController extends ControllerBase {
|
class DashboardController extends ControllerBase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Home page: spreadsheet-like line-by-line view of every ligne comptable.
|
||||||
|
*/
|
||||||
|
public function home() {
|
||||||
|
return [
|
||||||
|
'#theme' => 'figli_compta_home',
|
||||||
|
'#attached' => [
|
||||||
|
'library' => ['figli_compta_ledger/home'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secondary page: aggregate solde par compte / par client.
|
||||||
|
*/
|
||||||
public function view() {
|
public function view() {
|
||||||
return [
|
return [
|
||||||
'#theme' => 'figli_compta_dashboard',
|
'#theme' => 'figli_compta_dashboard',
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
{#
|
{#
|
||||||
Dashboard shell: Drupal renders the page (nav, auth, permissions).
|
Aggregate view: solde par compte / solde par client. The spreadsheet-like
|
||||||
dashboard.js (a Vue 3 app) fetches JSON:API and renders everything below,
|
line-by-line view is the site's home page (figli-compta-home.html.twig).
|
||||||
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
|
{% verbatim %} below: this is Vue template syntax, not Twig -- both use
|
||||||
{{ }}, so verbatim tells Twig to leave it alone and let Vue compile it
|
{{ }}, so verbatim tells Twig to leave it alone and let Vue compile it
|
||||||
@@ -10,9 +8,6 @@
|
|||||||
#}
|
#}
|
||||||
{% verbatim %}
|
{% verbatim %}
|
||||||
<div id="figli-dashboard-app">
|
<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-if="loading">Chargement des données…</p>
|
||||||
<p v-else-if="error" class="figli-error">Erreur de chargement du tableau de bord : {{ error }}</p>
|
<p v-else-if="error" class="figli-error">Erreur de chargement du tableau de bord : {{ error }}</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
{#
|
||||||
|
Dashboard shell: Drupal renders the page (nav, auth, permissions).
|
||||||
|
dashboard.js (Vue 3) fetches JSON:API and renders a spreadsheet-like table
|
||||||
|
of every ligne comptable, with filters and month/year grouping, 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 %}
|
||||||
|
<div id="figli-home-app">
|
||||||
|
<div class="figli-toolbar">
|
||||||
|
<a href="/node/add/ligne_comptable" class="button button--primary" @click.prevent="openAddForm">+ Ajouter une ligne</a>
|
||||||
|
|
||||||
|
<label>Compte
|
||||||
|
<select v-model="filterCompte">
|
||||||
|
<option value="">Tous</option>
|
||||||
|
<option v-for="c in allComptes" :key="c" :value="c">{{ c }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>Client
|
||||||
|
<select v-model="filterClient">
|
||||||
|
<option value="">Tous</option>
|
||||||
|
<option v-for="c in allClients" :key="c" :value="c">{{ c }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>Type
|
||||||
|
<select v-model="filterType">
|
||||||
|
<option value="">Tous</option>
|
||||||
|
<option v-for="t in allTypes" :key="t.value" :value="t.value">{{ t.label }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>Année
|
||||||
|
<select v-model="filterYear">
|
||||||
|
<option value="">Toutes</option>
|
||||||
|
<option v-for="y in allYears" :key="y" :value="y">{{ y }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>Regrouper par
|
||||||
|
<select v-model="groupBy">
|
||||||
|
<option value="none">Aucun</option>
|
||||||
|
<option value="month">Mois</option>
|
||||||
|
<option value="year">Année</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="figli-checkbox">
|
||||||
|
<input type="checkbox" v-model="onlyErrors" /> Écarts uniquement
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<span class="figli-count" v-if="!loading">{{ filteredRows.length }} / {{ rows.length }} lignes{{ errorCount ? ' — ' + errorCount + ' avec écart' : '' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="loading">Chargement des données…</p>
|
||||||
|
<p v-else-if="error" class="figli-error">Erreur de chargement : {{ error }}</p>
|
||||||
|
<div v-else class="figli-table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Libellé / Détail</th>
|
||||||
|
<th class="amount">Montant HT</th>
|
||||||
|
<th class="amount">Montant TTC</th>
|
||||||
|
<th v-for="c in allComptes" :key="c" class="amount compte-col">{{ c }}</th>
|
||||||
|
<th class="amount">Écart</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-for="item in groupedRows" :key="item.key">
|
||||||
|
<tr v-if="item.isGroup" class="figli-group-row">
|
||||||
|
<td :colspan="6 + allComptes.length + 1">{{ item.label }} <span class="figli-note">({{ item.count }} lignes)</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else :class="{'figli-error-row': item.hasError}">
|
||||||
|
<td>{{ item.date }}</td>
|
||||||
|
<td>{{ item.client || '—' }}</td>
|
||||||
|
<td><span class="figli-badge" :class="'type-' + item.type">{{ typeLabel(item.type) }}</span></td>
|
||||||
|
<td class="figli-libelle">{{ item.libelle }}</td>
|
||||||
|
<td class="amount">{{ formatEur(item.montant_ht) }}</td>
|
||||||
|
<td class="amount">{{ formatEur(item.montant_ttc) }}</td>
|
||||||
|
<td v-for="c in allComptes" :key="c" class="amount compte-col">{{ item.parCompte[c] !== undefined ? formatEur(item.parCompte[c]) : '' }}</td>
|
||||||
|
<td class="amount" :class="{'figli-ecart': item.hasError}">{{ item.hasError ? formatEur(item.ecart) : '' }}</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endverbatim %}
|
||||||
Reference in New Issue
Block a user