Add "Répartition de l'activité par type" chart to /dashboard/compte
Extends DashboardStatsController::stats()'s répartition-level SQL query to also group by type (not just année/compte), producing a new total_par_type_par_compte breakdown alongside the existing global one. Unlike the reconciliation tables on this page (deliberately entrée/ versement only), this chart covers every type touching the selected compte's répartition, matching what the general /dashboard already shows for the whole ledger. Fixed a latent bug the query change would otherwise have introduced: solde_par_compte_par_annee[année][compte] used to be a 1:1 assignment because each (année, compte) pair was unique in the old query -- adding type to the GROUP BY means several rows can now share that same pair, so it has to accumulate instead of overwrite (verified the accumulated totals exactly match a query without the type split, so this preserves existing behavior for the fields already in use). HBarChart in dashboard-compte.js gained the same colorFor prop dashboard.js's version already has (existing Top clients usage keeps its default green, unaffected). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,30 @@
|
|||||||
const EUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' });
|
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 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.'];
|
const MONTHS_SHORT = ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.'];
|
||||||
|
// Same labels/colors as dashboard.js's "Répartition de l'activité par
|
||||||
|
// type" -- duplicated rather than shared, see this file's docblock.
|
||||||
|
const TYPE_LABELS = {
|
||||||
|
entree: 'Entrée client',
|
||||||
|
charge: 'Charge structurelle',
|
||||||
|
versement: 'Versement freelance',
|
||||||
|
achat: 'Achat client',
|
||||||
|
hebergement: 'Hébergement',
|
||||||
|
sous_traitant: 'Sous-traitant',
|
||||||
|
salaire_stage: 'Salaire / stage',
|
||||||
|
charges_local_pro: 'Charges local pro',
|
||||||
|
autre: 'Autre',
|
||||||
|
};
|
||||||
|
const TYPE_COLORS = {
|
||||||
|
entree: '#1a7f37',
|
||||||
|
charge: '#6b7280',
|
||||||
|
versement: '#d97a0a',
|
||||||
|
achat: '#3b6fe0',
|
||||||
|
hebergement: '#0e9182',
|
||||||
|
sous_traitant: '#c9312b',
|
||||||
|
salaire_stage: '#0891b2',
|
||||||
|
charges_local_pro: '#65a30d',
|
||||||
|
autre: '#9061f0',
|
||||||
|
};
|
||||||
|
|
||||||
function resolve(includedMap, ref) {
|
function resolve(includedMap, ref) {
|
||||||
if (!ref) return null;
|
if (!ref) return null;
|
||||||
@@ -181,22 +205,31 @@
|
|||||||
'</div>',
|
'</div>',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Same shape as dashboard.js's HBarChart, colorFor included (used by
|
||||||
|
// the type-breakdown chart; Top clients below just omits it and gets
|
||||||
|
// the plain positive-green default).
|
||||||
const HBarChart = {
|
const HBarChart = {
|
||||||
props: {
|
props: {
|
||||||
items: { type: Array, required: true },
|
items: { type: Array, required: true },
|
||||||
formatValue: { type: Function, required: true },
|
formatValue: { type: Function, required: true },
|
||||||
|
colorFor: { type: Function, default: null },
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
maxAbs() {
|
maxAbs() {
|
||||||
return Math.max(1, ...this.items.map((i) => Math.abs(i.value)));
|
return Math.max(1, ...this.items.map((i) => Math.abs(i.value)));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
methods: {
|
||||||
|
fillColor(item) {
|
||||||
|
return this.colorFor ? this.colorFor(item) : 'var(--figli-positive)';
|
||||||
|
},
|
||||||
|
},
|
||||||
template:
|
template:
|
||||||
'<div class="figli-hbar-chart">' +
|
'<div class="figli-hbar-chart">' +
|
||||||
'<div class="figli-hbar-row" v-for="item in items" :key="item.label">' +
|
'<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-label" :title="item.label">{{ item.label }}</div>' +
|
||||||
'<div class="figli-hbar-track">' +
|
'<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 class="figli-hbar-fill" :style="{left: 0, width: (Math.abs(item.value) / maxAbs * 100) + \'%\', background: fillColor(item)}"></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="figli-hbar-value">{{ formatValue(item.value) }}</div>' +
|
'<div class="figli-hbar-value">{{ formatValue(item.value) }}</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
@@ -378,6 +411,20 @@
|
|||||||
.sort((a, b) => b.value - a.value)
|
.sort((a, b) => b.value - a.value)
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
},
|
},
|
||||||
|
// Same chart as /dashboard's "Répartition de l'activité par type",
|
||||||
|
// filtered to this compte -- unlike the reconciliation tables above
|
||||||
|
// (deliberately entrée/versement only, see this file's docblock),
|
||||||
|
// this comes straight from /dashboard/api/stats's per-compte
|
||||||
|
// breakdown, so it covers every type touching this compte's
|
||||||
|
// répartition (charge, achat, hébergement...), matching what the
|
||||||
|
// general dashboard shows for the whole ledger.
|
||||||
|
typeItems() {
|
||||||
|
if (!this.stats || !this.selectedCompte) return [];
|
||||||
|
const parType = this.stats.total_par_type_par_compte[this.selectedCompte] || {};
|
||||||
|
return Object.entries(parType)
|
||||||
|
.map(([type, value]) => ({ label: TYPE_LABELS[type] || type, value, type }))
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
formatEur(v) {
|
formatEur(v) {
|
||||||
@@ -386,6 +433,9 @@
|
|||||||
formatEurRound(v) {
|
formatEurRound(v) {
|
||||||
return EUR_ROUND.format(v);
|
return EUR_ROUND.format(v);
|
||||||
},
|
},
|
||||||
|
typeColor(item) {
|
||||||
|
return TYPE_COLORS[item.type] || '#6b7280';
|
||||||
|
},
|
||||||
formatDate(d) {
|
formatDate(d) {
|
||||||
if (!d) return '';
|
if (!d) return '';
|
||||||
const parts = d.split('-');
|
const parts = d.split('-');
|
||||||
|
|||||||
@@ -57,11 +57,14 @@ class DashboardStatsController extends ControllerBase {
|
|||||||
$nodeRows = $nodeQuery->execute()->fetchAll();
|
$nodeRows = $nodeQuery->execute()->fetchAll();
|
||||||
|
|
||||||
// Répartition-level aggregate: one row per (node, compte) répartition
|
// Répartition-level aggregate: one row per (node, compte) répartition
|
||||||
// share, pre-summed per (annee, compte) in SQL -- backs solde par
|
// share, pre-summed per (annee, compte, type) in SQL -- backs solde
|
||||||
// compte, both all-time and per-year (each year's own total already
|
// par compte, both all-time and per-year (each year's own total
|
||||||
// includes that year's ouverture line, so it *is* that year's closing
|
// already includes that year's ouverture line, so it *is* that
|
||||||
// balance -- same logic as LedgerStatsController::totauxAnnee()).
|
// year's closing balance -- same logic as
|
||||||
|
// LedgerStatsController::totauxAnnee()), and the per-compte type
|
||||||
|
// breakdown used by /dashboard/compte.
|
||||||
$compteQuery = $connection->select('node__field_date_ligne', 'd');
|
$compteQuery = $connection->select('node__field_date_ligne', 'd');
|
||||||
|
$compteQuery->innerJoin('node__field_type_ligne', 't2', 't2.entity_id = d.entity_id');
|
||||||
$compteQuery->innerJoin('node__field_repartition', 'r', 'r.entity_id = d.entity_id');
|
$compteQuery->innerJoin('node__field_repartition', 'r', 'r.entity_id = d.entity_id');
|
||||||
$compteQuery->innerJoin('paragraph__field_montant', 'm', 'm.entity_id = r.field_repartition_target_id');
|
$compteQuery->innerJoin('paragraph__field_montant', 'm', 'm.entity_id = r.field_repartition_target_id');
|
||||||
$compteQuery->innerJoin('paragraph__field_compte', 'c', 'c.entity_id = r.field_repartition_target_id');
|
$compteQuery->innerJoin('paragraph__field_compte', 'c', 'c.entity_id = r.field_repartition_target_id');
|
||||||
@@ -69,9 +72,11 @@ class DashboardStatsController extends ControllerBase {
|
|||||||
$compteQuery->condition('d.bundle', 'ligne_comptable');
|
$compteQuery->condition('d.bundle', 'ligne_comptable');
|
||||||
$compteQuery->addExpression('SUBSTRING(d.field_date_ligne_value, 1, 4)', 'annee');
|
$compteQuery->addExpression('SUBSTRING(d.field_date_ligne_value, 1, 4)', 'annee');
|
||||||
$compteQuery->addField('tc', 'name', 'compte');
|
$compteQuery->addField('tc', 'name', 'compte');
|
||||||
|
$compteQuery->addField('t2', 'field_type_ligne_value', 'type');
|
||||||
$compteQuery->addExpression('SUM(m.field_montant_value)', 'total');
|
$compteQuery->addExpression('SUM(m.field_montant_value)', 'total');
|
||||||
$compteQuery->groupBy('annee');
|
$compteQuery->groupBy('annee');
|
||||||
$compteQuery->groupBy('compte');
|
$compteQuery->groupBy('compte');
|
||||||
|
$compteQuery->groupBy('type');
|
||||||
$compteRows = $compteQuery->execute()->fetchAll();
|
$compteRows = $compteQuery->execute()->fetchAll();
|
||||||
|
|
||||||
// --- Aggregate the node-level rows in PHP. ---
|
// --- Aggregate the node-level rows in PHP. ---
|
||||||
@@ -114,16 +119,31 @@ class DashboardStatsController extends ControllerBase {
|
|||||||
// --- Aggregate the répartition-level rows in PHP. ---
|
// --- Aggregate the répartition-level rows in PHP. ---
|
||||||
$soldeParCompte = [];
|
$soldeParCompte = [];
|
||||||
$soldeParCompteParAnnee = [];
|
$soldeParCompteParAnnee = [];
|
||||||
|
$totalParTypeParCompte = [];
|
||||||
foreach ($compteRows as $row) {
|
foreach ($compteRows as $row) {
|
||||||
$total = (float) $row->total;
|
$total = (float) $row->total;
|
||||||
// Same reasoning: the all-time balance includes every line; the
|
// Same reasoning: the all-time balance includes every line; the
|
||||||
// per-year trend only makes sense for a real year.
|
// per-year trend only makes sense for a real year.
|
||||||
$soldeParCompte[$row->compte] = ($soldeParCompte[$row->compte] ?? 0) + $total;
|
$soldeParCompte[$row->compte] = ($soldeParCompte[$row->compte] ?? 0) + $total;
|
||||||
|
|
||||||
|
// Per-compte équivalent of $totalParType above -- every type
|
||||||
|
// counts here (not just entree/versement), same "hors ouverture,
|
||||||
|
// valeur absolue" convention.
|
||||||
|
if ($row->type !== 'ouverture') {
|
||||||
|
$totalParTypeParCompte[$row->compte][$row->type] =
|
||||||
|
($totalParTypeParCompte[$row->compte][$row->type] ?? 0) + abs($total);
|
||||||
|
}
|
||||||
|
|
||||||
if (!$this->isAnneeValide($row->annee)) {
|
if (!$this->isAnneeValide($row->annee)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$annees[$row->annee] = TRUE;
|
$annees[$row->annee] = TRUE;
|
||||||
$soldeParCompteParAnnee[$row->annee][$row->compte] = round($total, 2);
|
// compteRows now has one row per (année, compte, type) -- several
|
||||||
|
// types can share the same (année, compte), so this has to
|
||||||
|
// accumulate, not overwrite, or only the last type processed for
|
||||||
|
// that year+compte would survive.
|
||||||
|
$soldeParCompteParAnnee[$row->annee][$row->compte] =
|
||||||
|
round(($soldeParCompteParAnnee[$row->annee][$row->compte] ?? 0) + $total, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// array_keys() alone would leak PHP's array-key int-casting here: a
|
// array_keys() alone would leak PHP's array-key int-casting here: a
|
||||||
@@ -148,6 +168,10 @@ class DashboardStatsController extends ControllerBase {
|
|||||||
'top_clients' => $topClients,
|
'top_clients' => $topClients,
|
||||||
'solde_par_compte' => array_map(fn ($v) => round($v, 2), $soldeParCompte),
|
'solde_par_compte' => array_map(fn ($v) => round($v, 2), $soldeParCompte),
|
||||||
'solde_par_compte_par_annee' => $soldeParCompteParAnnee,
|
'solde_par_compte_par_annee' => $soldeParCompteParAnnee,
|
||||||
|
'total_par_type_par_compte' => array_map(
|
||||||
|
fn ($parType) => array_map(fn ($v) => round($v, 2), $parType),
|
||||||
|
$totalParTypeParCompte
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
@@ -144,6 +144,12 @@
|
|||||||
<year-bars-chart :years="evolutionSoldeYears" :format-value="formatEurRound"></year-bars-chart>
|
<year-bars-chart :years="evolutionSoldeYears" :format-value="formatEurRound"></year-bars-chart>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="figli-chart-section" v-if="typeItems.length">
|
||||||
|
<h2>Répartition de l'activité par type</h2>
|
||||||
|
<p class="figli-note">Montant total (HT, valeur absolue) par type de ligne pour {{ selectedCompte }}, hors ouvertures -- contrairement aux tableaux ci-dessus, tous les types comptent ici (pas seulement entrée/versement).</p>
|
||||||
|
<h-bar-chart :items="typeItems" :format-value="formatEurRound" :color-for="typeColor"></h-bar-chart>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="figli-chart-section" v-if="topClientsItems.length">
|
<section class="figli-chart-section" v-if="topClientsItems.length">
|
||||||
<h2>Top clients</h2>
|
<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>
|
<p class="figli-note">Clients ayant généré le plus d'entrées attribuées à {{ selectedCompte }}, toutes années confondues.</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user