From ceaf301bf047568083f5212cb468ae1175867f22 Mon Sep 17 00:00:00 2001 From: bach Date: Sun, 6 Sep 2026 14:29:27 +0200 Subject: [PATCH] =?UTF-8?q?Add=20per-compte=20dashboard:=20reste=20=C3=A0?= =?UTF-8?q?=20verser=20between=20entr=C3=A9es=20and=20versements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New /dashboard/compte page, one compte associé (freelance) at a time via a selector. Focused on entree/versement only (not achat/hébergement/ sous-traitant): reuses the same entrée<->versement reconciliation algorithm as the /lignes badges (a versement can settle several entrées at once, split equally), aggregated per compte to surface outstanding "reste à verser", over-paid entrées, and versements with no linked entrée at all -- plus solde/entrées-vs-versements/top-clients charts for context. Co-Authored-By: Claude Sonnet 5 --- .../figli_compta_ledger/css/dashboard.css | 100 +++++ .../figli_compta_ledger.libraries.yml | 10 + .../figli_compta_ledger.links.menu.yml | 10 +- .../figli_compta_ledger.module | 4 + .../figli_compta_ledger.routing.yml | 8 + .../js/dashboard-compte.js | 413 ++++++++++++++++++ .../src/Controller/DashboardController.php | 16 + .../figli-compta-dashboard-compte.html.twig | 128 ++++++ .../figli-compta-dashboard.html.twig | 1 + .../templates/figli-compta-home.html.twig | 1 + 10 files changed, 690 insertions(+), 1 deletion(-) create mode 100644 web/modules/custom/figli_compta_ledger/js/dashboard-compte.js create mode 100644 web/modules/custom/figli_compta_ledger/templates/figli-compta-dashboard-compte.html.twig diff --git a/web/modules/custom/figli_compta_ledger/css/dashboard.css b/web/modules/custom/figli_compta_ledger/css/dashboard.css index 7e27180..cc02be2 100644 --- a/web/modules/custom/figli_compta_ledger/css/dashboard.css +++ b/web/modules/custom/figli_compta_ledger/css/dashboard.css @@ -75,6 +75,106 @@ html.gin--dark-mode #figli-dashboard-app { } #figli-dashboard-app .figli-summary-value.is-positive { color: var(--figli-positive); } #figli-dashboard-app .figli-summary-value.is-negative { color: var(--figli-error); } +#figli-dashboard-app .figli-summary-card.is-alert { border-color: var(--figli-error); } + +/* --- Compte selector (dashboard par compte) --- */ +#figli-dashboard-app .figli-compte-selector { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 1.25rem; +} +#figli-dashboard-app .figli-compte-selector label { + font-size: 0.85rem; + font-weight: 600; + color: var(--figli-text-light); +} +#figli-dashboard-app .figli-compte-selector select { + font-size: 1rem; + font-weight: 600; + padding: 0.4rem 0.6rem; + border: 1px solid var(--figli-border); + border-radius: 6px; + background: var(--figli-bg); + color: var(--figli-text); +} + +/* --- Reconciliation tables (reste à verser, sur-versé, non liés) --- */ +#figli-dashboard-app .figli-recon-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} +#figli-dashboard-app .figli-recon-table th { + text-align: left; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.02em; + color: var(--figli-text-light); + padding: 0.3rem 0.6rem; + border-bottom: 1px solid var(--figli-border); +} +#figli-dashboard-app .figli-recon-table td { + padding: 0.4rem 0.6rem; + border-bottom: 1px solid var(--figli-border); + font-variant-numeric: tabular-nums; +} +#figli-dashboard-app .figli-recon-table td.is-negative { color: var(--figli-error); font-weight: 600; } +#figli-dashboard-app .figli-recon-table tbody tr:hover { background: var(--figli-bg-alt); } +#figli-dashboard-app .figli-recon-table tfoot td { font-weight: 700; border-bottom: none; border-top: 2px solid var(--figli-border); } +#figli-dashboard-app .figli-recon-table a { color: inherit; text-decoration: underline; text-decoration-color: var(--figli-border); } + +/* --- Year bars chart (évolution du solde, entrées vs versements) --- */ +#figli-dashboard-app .figli-year-chart { + display: flex; + align-items: stretch; + gap: 0.5rem; + height: 14rem; +} +#figli-dashboard-app .figli-year-col { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + min-width: 1.5rem; +} +#figli-dashboard-app .figli-year-track { + position: relative; + flex: 1; + width: 100%; +} +#figli-dashboard-app .figli-year-track::before { + content: ''; + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 1px; + background: var(--figli-border); +} +#figli-dashboard-app .figli-year-bars { + position: absolute; + inset: 0; + display: flex; + justify-content: center; + gap: 2px; +} +#figli-dashboard-app .figli-year-bar { + position: relative; + width: 0.9rem; + min-height: 2px; + border-radius: 2px; +} +@media (prefers-reduced-motion: no-preference) { + #figli-dashboard-app .figli-year-bar { + transition: height 0.3s ease; + } +} +#figli-dashboard-app .figli-year-label { + font-size: 0.78rem; + color: var(--figli-text-light); + margin-top: 0.4rem; +} /* --- Chart sections --- */ #figli-dashboard-app .figli-chart-section { 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 8a6aaf0..b1a89d9 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 @@ -25,6 +25,16 @@ dashboard: - core/drupal - figli_compta_ledger/vue +dashboard_compte: + js: + js/dashboard-compte.js: {} + css: + theme: + css/dashboard.css: {} + dependencies: + - core/drupal + - figli_compta_ledger/vue + admin_chrome: css: theme: 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 58a5346..082cb45 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 @@ -14,10 +14,18 @@ figli_compta_ledger.dashboard: parent: system.admin weight: -10 +figli_compta_ledger.dashboard_compte: + title: 'Tableau de bord par compte' + description: 'Entrées et versements par compte associé, reste à verser' + route_name: figli_compta_ledger.dashboard_compte + menu_name: admin + parent: system.admin + weight: -9 + figli_compta_ledger.history: title: 'Historique du grand livre' description: 'Toutes les révisions de toutes les lignes comptables' route_name: figli_compta_ledger.history menu_name: admin parent: system.admin - weight: -9 + weight: -8 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 6954ac1..092e6f6 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module @@ -223,6 +223,10 @@ function figli_compta_ledger_theme($existing, $type, $theme, $path) { 'variables' => ['current_route' => NULL], 'template' => 'figli-compta-dashboard', ], + 'figli_compta_dashboard_compte' => [ + 'variables' => ['current_route' => NULL], + 'template' => 'figli-compta-dashboard-compte', + ], ]; } 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 2846530..6766ba8 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 @@ -14,6 +14,14 @@ figli_compta_ledger.dashboard: requirements: _permission: 'access content' +figli_compta_ledger.dashboard_compte: + path: '/dashboard/compte' + defaults: + _controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::compteView' + _title: 'Tableau de bord par compte - SAS Figures Libres' + requirements: + _permission: 'access content' + figli_compta_ledger.history: path: '/lignes/historique' defaults: diff --git a/web/modules/custom/figli_compta_ledger/js/dashboard-compte.js b/web/modules/custom/figli_compta_ledger/js/dashboard-compte.js new file mode 100644 index 0000000..cee1b18 --- /dev/null +++ b/web/modules/custom/figli_compta_ledger/js/dashboard-compte.js @@ -0,0 +1,413 @@ +/** + * @file + * Dashboard par compte associé (freelance) : un compte à la fois, choisi + * dans un menu. L'objectif principal est de faire ressortir clairement les + * "versement freelance" pas (encore) compensés par une "entrée client" -- + * les autres types de ligne (charge, achat, hébergement, sous-traitant...) + * ne rentrent pas dans ce rapprochement, seuls entrée/versement comptent + * ici (contrairement à /lignes, où tout type "liable" est concerné). + * + * Comme dashboard.js : pas de librairie de graphes, tout est fait en + * div/CSS (voir dashboard.css) -- un dépendance de plus pour une poignée de + * barres n'en vaut pas la peine. + * + * Le rapprochement entrée/versement reprend exactement l'algorithme déjà + * en place dans home.js (reconciliationByEntree) : un versement peut être + * lié à plusieurs entrées à la fois (paiement groupé), auquel cas son + * montant est réparti à parts égales entre elles. Dupliqué ici plutôt que + * factorisé -- home.js et dashboard.js sont déjà deux fichiers autonomes + * sans module partagé, donc c'est la convention existante du projet, pas + * une entorse. + */ +(function (Drupal, Vue) { + 'use strict'; + + const API_BASE = '/jsonapi/node/ligne_comptable'; + const EUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }); + const EUR_ROUND = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }); + const MONTHS_SHORT = ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.']; + + function resolve(includedMap, ref) { + if (!ref) return null; + return includedMap.get(ref.type + ':' + ref.id) || null; + } + + // Same shape as home.js's buildRows() -- only the two types this page + // cares about ever reach it (see fetchEntreesEtVersements()). + 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 entreeLieeRefs = (rels.field_entree_liee && rels.field_entree_liee.data) || []; + const entreeLieeIds = entreeLieeRefs.map((ref) => ref.id); + const parCompte = {}; + 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; + } + rows.push({ + id: node.id, + nid: attrs.drupal_internal__nid, + date: attrs.field_date_ligne, + type: attrs.field_type_ligne, + client: clientTerm ? clientTerm.attributes.name : null, + libelle: attrs.field_notes || attrs.title, + parCompte, + entreeLieeIds, + }); + } + rows.sort((a, b) => (a.date || '').localeCompare(b.date || '')); + return rows; + } + + // Every entrée/versement, whatever their date -- unlike /lignes there's + // no sliding window here: this page needs the *complete* picture to + // reconcile a compte's entrées against its versements (one can easily + // be paid out a year or more after the other), and entrée+versement + // alone is a small enough slice of the ~1500+ line ledger to fetch in + // one page load (the rest -- charge/achat/hébergement/sous-traitant/ + // autre/ouverture -- is exactly what this page deliberately excludes). + async function fetchEntreesEtVersements() { + const params = new URLSearchParams(); + params.set('include', 'field_repartition,field_repartition.field_compte,field_client,field_entree_liee'); + params.set('filter[typeFilter][condition][path]', 'field_type_ligne'); + params.set('filter[typeFilter][condition][operator]', 'IN'); + params.append('filter[typeFilter][condition][value][]', 'entree'); + params.append('filter[typeFilter][condition][value][]', 'versement'); + params.set('page[limit]', '50'); + params.set('sort', 'field_date_ligne,drupal_internal__nid'); + let url = API_BASE + '?' + params.toString(); + 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; + } + const seen = new Set(); + const dedup = allData.filter((n) => (seen.has(n.id) ? false : (seen.add(n.id), true))); + return buildRows(dedup, includedMap); + } + + // page[limit]=200 would be silently clamped to core's hard cap of 50 -- + // same reasoning as fetchClientNames() in home.js -- but with only 8 + // comptes, one page always covers all of them; the pagination loop is + // kept anyway so this doesn't silently break if the vocabulary grows. + async function fetchComptes() { + let url = '/jsonapi/taxonomy_term/compte?sort=weight,name&page[limit]=50'; + const names = []; + 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(); + names.push(...(json.data || []).map((t) => t.attributes.name).filter(Boolean)); + url = json.links && json.links.next ? json.links.next.href : null; + } + return names; + } + + // Reuses the existing whole-ledger aggregate endpoint (plain SQL, all + // comptes/types/années at once) for the numbers that must reflect the + // *true* accounting balance -- solde par compte -- rather than + // recomputing a partial one from just entrée+versement rows, which + // would silently ignore that compte's charges/achats/etc. + async function fetchDashboardStats() { + const res = await fetch('/dashboard/api/stats', { headers: { Accept: 'application/json' } }); + if (!res.ok) throw new Error('/dashboard/api/stats a répondu ' + res.status); + return res.json(); + } + + function readHashCompte() { + const params = new URLSearchParams(location.hash.replace(/^#/, '')); + return params.get('compte') || ''; + } + + function writeHashCompte(compte) { + const params = new URLSearchParams(); + if (compte) params.set('compte', compte); + const hash = params.toString(); + history.replaceState(null, '', location.pathname + location.search + (hash ? '#' + hash : '')); + } + + // Vertical bar(s) per year, diverging from a zero baseline -- shared by + // "Évolution du solde" (one bar/year) and "Entrées vs versements par + // année" (two bars/year, side by side). A single flexible component + // instead of two near-identical ones. + const YearBarsChart = { + props: { + // [{ label, bars: [{ value, color, title }] }] + years: { type: Array, required: true }, + formatValue: { type: Function, required: true }, + }, + computed: { + maxAbs() { + return Math.max(1, ...this.years.flatMap((y) => y.bars.map((b) => Math.abs(b.value)))); + }, + }, + methods: { + barStyle(bar) { + const pct = (Math.abs(bar.value) / this.maxAbs) * 100; + return bar.value >= 0 + ? { bottom: '50%', height: pct / 2 + '%', background: bar.color } + : { top: '50%', height: pct / 2 + '%', background: bar.color }; + }, + }, + template: + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
{{ y.label }}
' + + '
' + + '
', + }; + + const HBarChart = { + props: { + items: { type: Array, required: true }, + formatValue: { type: Function, required: true }, + }, + computed: { + maxAbs() { + return Math.max(1, ...this.items.map((i) => Math.abs(i.value))); + }, + }, + template: + '
' + + '
' + + '
{{ item.label }}
' + + '
' + + '
' + + '
' + + '
{{ formatValue(item.value) }}
' + + '
' + + '
', + }; + + const App = { + components: { YearBarsChart, HBarChart }, + data() { + return { + loading: true, + error: null, + allRows: [], + comptes: [], + stats: null, + selectedCompte: '', + }; + }, + computed: { + sortiesByEntree() { + const map = new Map(); + for (const r of this.allRows) { + for (const entreeId of r.entreeLieeIds) { + if (!map.has(entreeId)) map.set(entreeId, []); + map.get(entreeId).push(r); + } + } + return map; + }, + // Same algorithm as home.js's reconciliationByEntree: per entrée, + // per compte, résidu = part de l'entrée pour ce compte + part des + // versements liés pour ce compte (montants négatifs), un versement + // lié à plusieurs entrées voyant son montant réparti à parts égales + // entre elles. + reconciliationByEntree() { + const map = new Map(); + for (const entreeRow of this.allRows) { + if (entreeRow.type !== 'entree') continue; + const linked = this.sortiesByEntree.get(entreeRow.id) || []; + const versementsParCompte = {}; + for (const s of linked) { + const share = s.entreeLieeIds.length || 1; + for (const [compte, montant] of Object.entries(s.parCompte)) { + versementsParCompte[compte] = (versementsParCompte[compte] || 0) + montant / share; + } + } + const comptes = new Set([...Object.keys(entreeRow.parCompte), ...Object.keys(versementsParCompte)]); + const parCompteResidual = {}; + for (const c of comptes) { + parCompteResidual[c] = Math.round(((entreeRow.parCompte[c] || 0) + (versementsParCompte[c] || 0)) * 100) / 100; + } + map.set(entreeRow.id, { parCompteResidual }); + } + return map; + }, + entreesDuCompte() { + if (!this.selectedCompte) return []; + return this.allRows.filter((r) => r.type === 'entree' && r.parCompte[this.selectedCompte] !== undefined); + }, + // Entrées dont la part du compte sélectionné n'est pas (entièrement) + // versée -- résidu positif = encore dû, négatif = sur-versé. C'est + // le coeur de la page : ce que le collectif doit encore à ce + // compte associé, entrée par entrée. + resteAVerserRows() { + return this.entreesDuCompte + .map((entree) => { + const recon = this.reconciliationByEntree.get(entree.id); + const residual = recon ? (recon.parCompteResidual[this.selectedCompte] ?? entree.parCompte[this.selectedCompte]) : entree.parCompte[this.selectedCompte]; + return { + entree, + montantAttribue: Math.round(entree.parCompte[this.selectedCompte] * 100) / 100, + residual, + dejaVerse: Math.round((entree.parCompte[this.selectedCompte] - residual) * 100) / 100, + }; + }) + .filter((r) => Math.abs(r.residual) > 0.01) + .sort((a, b) => (a.entree.date || '').localeCompare(b.entree.date || '')); + }, + resteAVerserPositif() { + return this.resteAVerserRows.filter((r) => r.residual > 0.01); + }, + surVerseRows() { + return this.resteAVerserRows.filter((r) => r.residual < -0.01); + }, + // Versements de ce compte qui ne pointent vers aucune entrée du + // tout -- ni "reste à verser" ni "sur-versé" ne les couvre (ces + // deux listes ne regardent que les *entrées*), donc sans ça un + // versement orphelin resterait invisible alors que c'est justement + // le genre d'anomalie que cette page doit faire ressortir. + versementsNonLies() { + if (!this.selectedCompte) return []; + return this.allRows + .filter((r) => r.type === 'versement' && r.parCompte[this.selectedCompte] !== undefined && r.entreeLieeIds.length === 0) + .sort((a, b) => (b.date || '').localeCompare(a.date || '')); + }, + totalResteAVerser() { + return Math.round(this.resteAVerserPositif.reduce((sum, r) => sum + r.residual, 0) * 100) / 100; + }, + totalSurVerse() { + return Math.round(this.surVerseRows.reduce((sum, r) => sum - r.residual, 0) * 100) / 100; + }, + totalNonLies() { + return Math.round(this.versementsNonLies.reduce((sum, r) => sum - (r.parCompte[this.selectedCompte] || 0), 0) * 100) / 100; + }, + soldeActuel() { + if (!this.stats || !this.selectedCompte) return 0; + return this.stats.solde_par_compte[this.selectedCompte] || 0; + }, + // Une seule barre/année, verte au-dessus de zéro / rouge en + // dessous -- solde de clôture de ce compte, année par année (même + // source que /dashboard, filtrée à ce seul compte). + evolutionSoldeYears() { + if (!this.stats || !this.selectedCompte) return []; + return this.stats.annees.map((y) => { + const v = this.stats.solde_par_compte_par_annee[y] ? this.stats.solde_par_compte_par_annee[y][this.selectedCompte] : undefined; + const value = v !== undefined ? v : 0; + return { + label: y, + bars: [{ value, color: value >= 0 ? 'var(--figli-positive)' : 'var(--figli-error)', title: 'Solde ' + y }], + }; + }); + }, + // Deux barres/année : entrées attribuées à ce compte (vert, vers le + // haut) et versements de ce compte (rouge, déjà négatifs -- vers le + // bas) -- répond visuellement, année par année, à la question + // centrale de cette page. + entreeVsVersementYears() { + if (!this.stats || !this.selectedCompte) return []; + const parAnnee = new Map(); + for (const y of this.stats.annees) parAnnee.set(y, { entree: 0, versement: 0 }); + for (const r of this.allRows) { + const montant = r.parCompte[this.selectedCompte]; + if (montant === undefined || !r.date) continue; + const y = r.date.slice(0, 4); + if (!parAnnee.has(y)) continue; + const bucket = parAnnee.get(y); + if (r.type === 'entree') bucket.entree += montant; + else bucket.versement += montant; + } + return this.stats.annees.map((y) => { + const b = parAnnee.get(y); + return { + label: y, + bars: [ + { value: Math.round(b.entree * 100) / 100, color: 'var(--figli-positive)', title: 'Entrées ' + y }, + { value: Math.round(b.versement * 100) / 100, color: 'var(--figli-error)', title: 'Versements ' + y }, + ], + }; + }); + }, + topClientsItems() { + const parClient = new Map(); + for (const r of this.entreesDuCompte) { + const client = r.client || '(sans client)'; + parClient.set(client, (parClient.get(client) || 0) + r.parCompte[this.selectedCompte]); + } + return Array.from(parClient.entries()) + .map(([label, value]) => ({ label, value: Math.round(value * 100) / 100 })) + .sort((a, b) => b.value - a.value) + .slice(0, 10); + }, + }, + methods: { + formatEur(v) { + return v === null || v === undefined ? '' : EUR.format(v); + }, + formatEurRound(v) { + return EUR_ROUND.format(v); + }, + formatDate(d) { + if (!d) return ''; + const parts = d.split('-'); + return parts[2] + ' ' + MONTHS_SHORT[parseInt(parts[1], 10) - 1] + ' ' + parts[0]; + }, + // Ouvre le grand livre déjà filtré sur ce client -- pour aller voir + // le détail des lignes plutôt que de dupliquer une vue détaillée + // ici. + ligneHref(client) { + return '/lignes#client=' + encodeURIComponent(client) + '&type=entree,versement'; + }, + selectCompte(compte) { + this.selectedCompte = compte; + writeHashCompte(compte); + }, + async load() { + this.loading = true; + this.error = null; + try { + const [allRows, comptes, stats] = await Promise.all([ + fetchEntreesEtVersements(), + fetchComptes(), + fetchDashboardStats(), + ]); + this.allRows = allRows; + this.comptes = comptes; + this.stats = stats; + const fromHash = readHashCompte(); + this.selectedCompte = fromHash && comptes.includes(fromHash) ? fromHash : (comptes[0] || ''); + } catch (err) { + this.error = err.message; + } finally { + this.loading = false; + } + }, + }, + mounted() { + this.load(); + }, + }; + + Drupal.behaviors.figliComptaDashboardCompte = { + attach(context) { + const root = context.querySelector ? context.querySelector('#figli-dashboard-app') : null; + if (root && !root.dataset.figliInitialized) { + root.dataset.figliInitialized = '1'; + Vue.createApp(App).mount(root); + } + }, + }; +})(Drupal, Vue); 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 b76a44c..4e86fb4 100644 --- a/web/modules/custom/figli_compta_ledger/src/Controller/DashboardController.php +++ b/web/modules/custom/figli_compta_ledger/src/Controller/DashboardController.php @@ -39,4 +39,20 @@ class DashboardController extends ControllerBase { ]; } + /** + * Third page: one compte associé (freelance) at a time -- entrées client + * vs versements freelance, and above all which entrées haven't been + * (fully) paid out yet. Complements the aggregate /dashboard above, + * which mixes every compte and every type together. + */ + public function compteView() { + return [ + '#theme' => 'figli_compta_dashboard_compte', + '#current_route' => 'figli_compta_ledger.dashboard_compte', + '#attached' => [ + 'library' => ['figli_compta_ledger/dashboard_compte'], + ], + ]; + } + } diff --git a/web/modules/custom/figli_compta_ledger/templates/figli-compta-dashboard-compte.html.twig b/web/modules/custom/figli_compta_ledger/templates/figli-compta-dashboard-compte.html.twig new file mode 100644 index 0000000..ab44e5f --- /dev/null +++ b/web/modules/custom/figli_compta_ledger/templates/figli-compta-dashboard-compte.html.twig @@ -0,0 +1,128 @@ +{# + Dashboard par compte associé (freelance) : un compte à la fois, choisi + dans le sélecteur. Se concentre sur entrée client / versement freelance + uniquement -- l'objectif principal est de faire ressortir les versements + pas (encore) compensés par une entrée, pas de donner une vue comptable + complète (voir /dashboard pour ça). + + {% verbatim %} ci-dessous : syntaxe Vue, pas Twig -- voir + figli-compta-dashboard.html.twig pour la même remarque. +#} + +{% verbatim %} +
+

Chargement des données…

+

Erreur de chargement du tableau de bord : {{ error }}

+ +
+{% endverbatim %} 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 fbfb62a..bfb69a1 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 @@ -10,6 +10,7 @@ {% verbatim %}
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 index 1d00701..25300a7 100644 --- 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 @@ -8,6 +8,7 @@ {% verbatim %}