diff --git a/config/sync/field.storage.node.field_type_ligne.yml b/config/sync/field.storage.node.field_type_ligne.yml index 10e1273..29ecc99 100644 --- a/config/sync/field.storage.node.field_type_ligne.yml +++ b/config/sync/field.storage.node.field_type_ligne.yml @@ -26,6 +26,9 @@ settings: - value: ouverture label: "Ligne d'ouverture" + - + value: autre + label: Autre allowed_values_function: '' module: options locked: false diff --git a/config/sync/system.site.yml b/config/sync/system.site.yml index 5371453..14354be 100644 --- a/config/sync/system.site.yml +++ b/config/sync/system.site.yml @@ -8,7 +8,7 @@ slogan: '' page: 403: '' 404: '' - front: /node + front: /lignes admin_compact_mode: false weight_select_max: 100 default_langcode: en diff --git a/web/modules/custom/figli_compta_ledger/css/home.css b/web/modules/custom/figli_compta_ledger/css/home.css new file mode 100644 index 0000000..ef905a3 --- /dev/null +++ b/web/modules/custom/figli_compta_ledger/css/home.css @@ -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; +} diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.libraries.yml b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.libraries.yml index f735deb..dff120b 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.libraries.yml +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.libraries.yml @@ -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 diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.links.menu.yml b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.links.menu.yml index b249bba..3b673cd 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.links.menu.yml +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.links.menu.yml @@ -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' diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module index 9eaf223..430ce3c 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module @@ -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', diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml index 1fa4341..0d2148b 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml @@ -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: diff --git a/web/modules/custom/figli_compta_ledger/js/dashboard.js b/web/modules/custom/figli_compta_ledger/js/dashboard.js index cd9e916..14b0033 100644 --- a/web/modules/custom/figli_compta_ledger/js/dashboard.js +++ b/web/modules/custom/figli_compta_ledger/js/dashboard.js @@ -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
(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); diff --git a/web/modules/custom/figli_compta_ledger/js/home.js b/web/modules/custom/figli_compta_ledger/js/home.js new file mode 100644 index 0000000..ba9712c --- /dev/null +++ b/web/modules/custom/figli_compta_ledger/js/home.js @@ -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); diff --git a/web/modules/custom/figli_compta_ledger/src/Controller/DashboardController.php b/web/modules/custom/figli_compta_ledger/src/Controller/DashboardController.php index f377b2e..e335921 100644 --- a/web/modules/custom/figli_compta_ledger/src/Controller/DashboardController.php +++ b/web/modules/custom/figli_compta_ledger/src/Controller/DashboardController.php @@ -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', diff --git a/web/modules/custom/figli_compta_ledger/templates/figli-compta-dashboard.html.twig b/web/modules/custom/figli_compta_ledger/templates/figli-compta-dashboard.html.twig index 548dd53..1567092 100644 --- a/web/modules/custom/figli_compta_ledger/templates/figli-compta-dashboard.html.twig +++ b/web/modules/custom/figli_compta_ledger/templates/figli-compta-dashboard.html.twig @@ -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 %}Chargement des données…
Erreur de chargement du tableau de bord : {{ error }}
diff --git a/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig b/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig new file mode 100644 index 0000000..e47a113 --- /dev/null +++ b/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig @@ -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 %} +Chargement des données…
+Erreur de chargement : {{ error }}
+| Date | +Client | +Type | +Libellé / Détail | +Montant HT | +Montant TTC | +{{ c }} | +Écart | +
|---|---|---|---|---|---|---|---|
| {{ item.label }} ({{ item.count }} lignes) | +|||||||
| {{ item.date }} | +{{ item.client || '—' }} | +{{ typeLabel(item.type) }} | +{{ item.libelle }} | +{{ formatEur(item.montant_ht) }} | +{{ formatEur(item.montant_ttc) }} | +{{ item.parCompte[c] !== undefined ? formatEur(item.parCompte[c]) : '' }} | +{{ item.hasError ? formatEur(item.ecart) : '' }} | +