Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10f6271fec |
@@ -26,6 +26,9 @@ settings:
|
||||
-
|
||||
value: ouverture
|
||||
label: "Ligne d'ouverture"
|
||||
-
|
||||
value: autre
|
||||
label: Autre
|
||||
allowed_values_function: ''
|
||||
module: options
|
||||
locked: false
|
||||
|
||||
@@ -8,7 +8,7 @@ slogan: ''
|
||||
page:
|
||||
403: ''
|
||||
404: ''
|
||||
front: /node
|
||||
front: /lignes
|
||||
admin_compact_mode: false
|
||||
weight_select_max: 100
|
||||
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: {}
|
||||
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:
|
||||
js:
|
||||
js/dashboard.js: {}
|
||||
@@ -11,6 +23,4 @@ dashboard:
|
||||
css/dashboard.css: {}
|
||||
dependencies:
|
||||
- core/drupal
|
||||
- core/drupal.dialog.ajax
|
||||
- core/jquery
|
||||
- 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:
|
||||
title: 'Tableau de bord'
|
||||
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
|
||||
* or drush scripts) -- the form above should normally catch this first with
|
||||
* 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) {
|
||||
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')) {
|
||||
return;
|
||||
}
|
||||
if (\Drupal::state()->get('figli_compta_ledger.skip_validation', FALSE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$montant_ht = (float) $node->get('field_montant_ht')->value;
|
||||
$somme = 0.0;
|
||||
@@ -131,6 +141,10 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
|
||||
*/
|
||||
function figli_compta_ledger_theme($existing, $type, $theme, $path) {
|
||||
return [
|
||||
'figli_compta_home' => [
|
||||
'variables' => [],
|
||||
'template' => 'figli-compta-home',
|
||||
],
|
||||
'figli_compta_dashboard' => [
|
||||
'variables' => [],
|
||||
'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:
|
||||
path: '/dashboard'
|
||||
defaults:
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
/**
|
||||
* @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.
|
||||
* Aggregate dashboard: solde par compte / solde par client, computed
|
||||
* client-side from JSON:API. The line-by-line spreadsheet view is the
|
||||
* site's home page (home.js), not this one.
|
||||
*/
|
||||
(function (Drupal, Vue, jQuery) {
|
||||
(function (Drupal, Vue) {
|
||||
'use strict';
|
||||
|
||||
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';
|
||||
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);
|
||||
}
|
||||
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));
|
||||
@@ -44,21 +40,17 @@
|
||||
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);
|
||||
}
|
||||
@@ -81,12 +73,7 @@
|
||||
|
||||
const App = {
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
error: null,
|
||||
tables: [],
|
||||
lineCount: 0,
|
||||
};
|
||||
return { loading: true, error: null, tables: [], lineCount: 0 };
|
||||
},
|
||||
methods: {
|
||||
formatEur(v) {
|
||||
@@ -97,14 +84,6 @@
|
||||
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;
|
||||
@@ -115,18 +94,8 @@
|
||||
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),
|
||||
},
|
||||
{ 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;
|
||||
@@ -137,10 +106,6 @@
|
||||
},
|
||||
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());
|
||||
},
|
||||
};
|
||||
|
||||
@@ -150,11 +115,7 @@
|
||||
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);
|
||||
})(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;
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Renders the JS-driven pages (progressive decoupling: Drupal serves the
|
||||
* page shell + auth via the session cookie, the JS fetches JSON:API and
|
||||
* does all the aggregation/rendering client-side).
|
||||
*/
|
||||
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() {
|
||||
return [
|
||||
'#theme' => 'figli_compta_dashboard',
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
{#
|
||||
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.
|
||||
Aggregate view: solde par compte / solde par client. The spreadsheet-like
|
||||
line-by-line view is the site's home page (figli-compta-home.html.twig).
|
||||
|
||||
{% verbatim %} below: this is Vue template syntax, not Twig -- both use
|
||||
{{ }}, so verbatim tells Twig to leave it alone and let Vue compile it
|
||||
@@ -10,9 +8,6 @@
|
||||
#}
|
||||
{% 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>
|
||||
|
||||
@@ -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