Add per-compte dashboard: reste à verser between entrées and versements
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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-positive { color: var(--figli-positive); }
|
||||||
#figli-dashboard-app .figli-summary-value.is-negative { color: var(--figli-error); }
|
#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 --- */
|
/* --- Chart sections --- */
|
||||||
#figli-dashboard-app .figli-chart-section {
|
#figli-dashboard-app .figli-chart-section {
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ dashboard:
|
|||||||
- core/drupal
|
- core/drupal
|
||||||
- figli_compta_ledger/vue
|
- 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:
|
admin_chrome:
|
||||||
css:
|
css:
|
||||||
theme:
|
theme:
|
||||||
|
|||||||
@@ -14,10 +14,18 @@ figli_compta_ledger.dashboard:
|
|||||||
parent: system.admin
|
parent: system.admin
|
||||||
weight: -10
|
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:
|
figli_compta_ledger.history:
|
||||||
title: 'Historique du grand livre'
|
title: 'Historique du grand livre'
|
||||||
description: 'Toutes les révisions de toutes les lignes comptables'
|
description: 'Toutes les révisions de toutes les lignes comptables'
|
||||||
route_name: figli_compta_ledger.history
|
route_name: figli_compta_ledger.history
|
||||||
menu_name: admin
|
menu_name: admin
|
||||||
parent: system.admin
|
parent: system.admin
|
||||||
weight: -9
|
weight: -8
|
||||||
|
|||||||
@@ -223,6 +223,10 @@ function figli_compta_ledger_theme($existing, $type, $theme, $path) {
|
|||||||
'variables' => ['current_route' => NULL],
|
'variables' => ['current_route' => NULL],
|
||||||
'template' => 'figli-compta-dashboard',
|
'template' => 'figli-compta-dashboard',
|
||||||
],
|
],
|
||||||
|
'figli_compta_dashboard_compte' => [
|
||||||
|
'variables' => ['current_route' => NULL],
|
||||||
|
'template' => 'figli-compta-dashboard-compte',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ figli_compta_ledger.dashboard:
|
|||||||
requirements:
|
requirements:
|
||||||
_permission: 'access content'
|
_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:
|
figli_compta_ledger.history:
|
||||||
path: '/lignes/historique'
|
path: '/lignes/historique'
|
||||||
defaults:
|
defaults:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
'<div class="figli-year-chart">' +
|
||||||
|
'<div class="figli-year-col" v-for="y in years" :key="y.label">' +
|
||||||
|
'<div class="figli-year-track">' +
|
||||||
|
'<div class="figli-year-bars">' +
|
||||||
|
'<div class="figli-year-bar" v-for="(bar, i) in y.bars" :key="i" :style="barStyle(bar)" :title="bar.title + \' : \' + formatValue(bar.value)"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="figli-year-label">{{ y.label }}</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>',
|
||||||
|
};
|
||||||
|
|
||||||
|
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:
|
||||||
|
'<div class="figli-hbar-chart">' +
|
||||||
|
'<div class="figli-hbar-row" v-for="item in items" :key="item.label">' +
|
||||||
|
'<div class="figli-hbar-label" :title="item.label">{{ item.label }}</div>' +
|
||||||
|
'<div class="figli-hbar-track">' +
|
||||||
|
'<div class="figli-hbar-fill" :style="{left: 0, width: (Math.abs(item.value) / maxAbs * 100) + \'%\', background: \'var(--figli-positive)\'}"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="figli-hbar-value">{{ formatValue(item.value) }}</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>',
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -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'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+128
@@ -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.
|
||||||
|
#}
|
||||||
|
<nav class="figli-page-nav">
|
||||||
|
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
|
||||||
|
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">Dashboard</a>
|
||||||
|
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a>
|
||||||
|
</nav>
|
||||||
|
{% verbatim %}
|
||||||
|
<div id="figli-dashboard-app">
|
||||||
|
<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>
|
||||||
|
<div class="figli-compte-selector">
|
||||||
|
<label for="figli-compte-select">Compte associé</label>
|
||||||
|
<select id="figli-compte-select" :value="selectedCompte" @change="selectCompte($event.target.value)">
|
||||||
|
<option v-for="c in comptes" :key="c" :value="c">{{ c }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="figli-summary-row">
|
||||||
|
<div class="figli-summary-card">
|
||||||
|
<div class="figli-summary-label">Solde actuel</div>
|
||||||
|
<div class="figli-summary-value" :class="soldeActuel < 0 ? 'is-negative' : 'is-positive'">{{ formatEurRound(soldeActuel) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="figli-summary-card" :class="{'is-alert': totalResteAVerser > 0.01}">
|
||||||
|
<div class="figli-summary-label">Reste à verser</div>
|
||||||
|
<div class="figli-summary-value" :class="totalResteAVerser > 0.01 ? 'is-negative' : ''">{{ formatEurRound(totalResteAVerser) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="figli-summary-card" v-if="totalSurVerse > 0.01">
|
||||||
|
<div class="figli-summary-label">Sur-versé</div>
|
||||||
|
<div class="figli-summary-value is-negative">{{ formatEurRound(totalSurVerse) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="figli-summary-card" :class="{'is-alert': totalNonLies > 0.01}" v-if="versementsNonLies.length">
|
||||||
|
<div class="figli-summary-label">Versements non liés</div>
|
||||||
|
<div class="figli-summary-value is-negative">{{ formatEurRound(totalNonLies) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="figli-chart-section">
|
||||||
|
<h2>Reste à verser -- entrées non (entièrement) compensées</h2>
|
||||||
|
<p class="figli-note">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.</p>
|
||||||
|
<table v-if="resteAVerserPositif.length" class="figli-recon-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Date</th><th>Client</th><th>Libellé</th><th>Attribué</th><th>Déjà versé</th><th>Reste à verser</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in resteAVerserPositif" :key="r.entree.id">
|
||||||
|
<td>{{ formatDate(r.entree.date) }}</td>
|
||||||
|
<td><a :href="ligneHref(r.entree.client)">{{ r.entree.client || '—' }}</a></td>
|
||||||
|
<td>{{ r.entree.libelle || '—' }}</td>
|
||||||
|
<td>{{ formatEur(r.montantAttribue) }}</td>
|
||||||
|
<td>{{ formatEur(r.dejaVerse) }}</td>
|
||||||
|
<td class="is-negative">{{ formatEur(r.residual) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr><td colspan="5">Total</td><td class="is-negative">{{ formatEurRound(totalResteAVerser) }}</td></tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
<p v-else class="figli-note">Rien en attente -- toutes les entrées de ce compte sont compensées.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="figli-chart-section" v-if="surVerseRows.length">
|
||||||
|
<h2>Entrées sur-versées</h2>
|
||||||
|
<p class="figli-note">Le montant versé pour ces entrées dépasse la part attribuée à {{ selectedCompte }} -- à vérifier.</p>
|
||||||
|
<table class="figli-recon-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Date</th><th>Client</th><th>Libellé</th><th>Attribué</th><th>Versé</th><th>Sur-versé</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in surVerseRows" :key="r.entree.id">
|
||||||
|
<td>{{ formatDate(r.entree.date) }}</td>
|
||||||
|
<td><a :href="ligneHref(r.entree.client)">{{ r.entree.client || '—' }}</a></td>
|
||||||
|
<td>{{ r.entree.libelle || '—' }}</td>
|
||||||
|
<td>{{ formatEur(r.montantAttribue) }}</td>
|
||||||
|
<td>{{ formatEur(r.dejaVerse) }}</td>
|
||||||
|
<td class="is-negative">{{ formatEur(-r.residual) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="figli-chart-section" v-if="versementsNonLies.length">
|
||||||
|
<h2>Versements sans entrée liée</h2>
|
||||||
|
<p class="figli-note">Versements de {{ selectedCompte }} qui ne pointent vers aucune entrée client -- ni "reste à verser" ni "sur-versé" ci-dessus ne les couvre.</p>
|
||||||
|
<table class="figli-recon-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Date</th><th>Client</th><th>Libellé</th><th>Montant</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in versementsNonLies" :key="r.id">
|
||||||
|
<td>{{ formatDate(r.date) }}</td>
|
||||||
|
<td><a :href="ligneHref(r.client)">{{ r.client || '—' }}</a></td>
|
||||||
|
<td>{{ r.libelle || '—' }}</td>
|
||||||
|
<td class="is-negative">{{ formatEur(r.parCompte[selectedCompte]) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="figli-chart-section">
|
||||||
|
<h2>Entrées vs versements par année</h2>
|
||||||
|
<p class="figli-note">Part de {{ selectedCompte }} dans les entrées client (vert) et ses versements (rouge), année par année.</p>
|
||||||
|
<year-bars-chart :years="entreeVsVersementYears" :format-value="formatEurRound"></year-bars-chart>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="figli-chart-section">
|
||||||
|
<h2>Évolution du solde</h2>
|
||||||
|
<p class="figli-note">Solde de clôture de {{ selectedCompte }}, année par année (ouverture comprise).</p>
|
||||||
|
<year-bars-chart :years="evolutionSoldeYears" :format-value="formatEurRound"></year-bars-chart>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="figli-chart-section" v-if="topClientsItems.length">
|
||||||
|
<h2>Top clients</h2>
|
||||||
|
<p class="figli-note">Clients ayant généré le plus d'entrées attribuées à {{ selectedCompte }}, toutes années confondues.</p>
|
||||||
|
<h-bar-chart :items="topClientsItems" :format-value="formatEurRound"></h-bar-chart>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
{% endverbatim %}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
<nav class="figli-page-nav">
|
<nav class="figli-page-nav">
|
||||||
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
|
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
|
||||||
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">Dashboard</a>
|
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">Dashboard</a>
|
||||||
|
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a>
|
||||||
</nav>
|
</nav>
|
||||||
{% verbatim %}
|
{% verbatim %}
|
||||||
<div id="figli-dashboard-app">
|
<div id="figli-dashboard-app">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<nav class="figli-page-nav">
|
<nav class="figli-page-nav">
|
||||||
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
|
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
|
||||||
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">Dashboard</a>
|
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">Dashboard</a>
|
||||||
|
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a>
|
||||||
</nav>
|
</nav>
|
||||||
{% verbatim %}
|
{% verbatim %}
|
||||||
<div id="figli-home-app">
|
<div id="figli-home-app">
|
||||||
|
|||||||
Reference in New Issue
Block a user