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:
2026-09-03 22:52:00 +02:00
co-authored by Claude Sonnet 5
parent ca800be21a
commit 10f6271fec
12 changed files with 496 additions and 61 deletions
@@ -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
+1 -1
View File
@@ -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 %}