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: + '
Chargement des données…
+Erreur de chargement du tableau de bord : {{ error }}
+ +Pour {{ selectedCompte }} : entrées client dont la part attribuée n'a pas encore été entièrement reversée. Triées de la plus ancienne à la plus récente.
+| Date | Client | Libellé | Attribué | Déjà versé | Reste à verser |
|---|---|---|---|---|---|
| {{ formatDate(r.entree.date) }} | +{{ r.entree.client || '—' }} | +{{ r.entree.libelle || '—' }} | +{{ formatEur(r.montantAttribue) }} | +{{ formatEur(r.dejaVerse) }} | +{{ formatEur(r.residual) }} | +
| Total | {{ formatEurRound(totalResteAVerser) }} | ||||
Rien en attente -- toutes les entrées de ce compte sont compensées.
+Le montant versé pour ces entrées dépasse la part attribuée à {{ selectedCompte }} -- à vérifier.
+| Date | Client | Libellé | Attribué | Versé | Sur-versé |
|---|---|---|---|---|---|
| {{ formatDate(r.entree.date) }} | +{{ r.entree.client || '—' }} | +{{ r.entree.libelle || '—' }} | +{{ formatEur(r.montantAttribue) }} | +{{ formatEur(r.dejaVerse) }} | +{{ formatEur(-r.residual) }} | +
Versements de {{ selectedCompte }} qui ne pointent vers aucune entrée client -- ni "reste à verser" ni "sur-versé" ci-dessus ne les couvre.
+| Date | Client | Libellé | Montant |
|---|---|---|---|
| {{ formatDate(r.date) }} | +{{ r.client || '—' }} | +{{ r.libelle || '—' }} | +{{ formatEur(r.parCompte[selectedCompte]) }} | +
Part de {{ selectedCompte }} dans les entrées client (vert) et ses versements (rouge), année par année.
+Solde de clôture de {{ selectedCompte }}, année par année (ouverture comprise).
+Clients ayant généré le plus d'entrées attribuées à {{ selectedCompte }}, toutes années confondues.
+