Add solde footers + per-année small multiples to /dashboard/compte
1. "Entrées sur-versées" and "Versements sans entrée liée" tables get a solde footer, same convention as "Reste à verser" above them -- reuses the totalSurVerse/totalNonLies computeds already backing the summary cards, no new computation. 2. "Répartition de l'activité par type" and "Top clients" keep their all-time chart, now followed by a small-multiples grid of the same chart per année. The type breakdown reuses total_par_type_par_compte_par_annee, a new field on DashboardStatsController::stats() built from the same répartition- level rows already fetched for total_par_type_par_compte (no extra query, just one more level of grouping in the PHP aggregation). Top clients per année is computed client-side from the same entreesDuCompte already used for the all-time version, capped to top 5 (not 10) to keep the grid readable. HBarChart gains a `compact` prop (narrower fixed columns, smaller text) for use inside the small-multiples cards -- the all-time charts above keep the full-width layout unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -275,6 +275,48 @@ html.gin--dark-mode #figli-dashboard-app {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compact variant -- same chart, narrower fixed columns and smaller
|
||||||
|
text so it fits inside a small-multiples grid card (see
|
||||||
|
.figli-year-hbar-grid below) instead of the full-width layout. */
|
||||||
|
#figli-dashboard-app .figli-hbar-chart.is-compact {
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-hbar-chart.is-compact .figli-hbar-row {
|
||||||
|
grid-template-columns: 5.5rem 1fr 3.5rem;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-hbar-chart.is-compact .figli-hbar-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-hbar-chart.is-compact .figli-hbar-track {
|
||||||
|
height: 0.65rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-hbar-chart.is-compact .figli-hbar-value {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Year small multiples (par année, next to the all-time chart) --- */
|
||||||
|
#figli-dashboard-app .figli-year-hbar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
border-top: 1px solid var(--figli-border);
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-year-hbar-card {
|
||||||
|
background: var(--figli-bg-alt);
|
||||||
|
border: 1px solid var(--figli-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-year-hbar-title {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--figli-text-light);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Vertical bar chart (CA par année) --- */
|
/* --- Vertical bar chart (CA par année) --- */
|
||||||
#figli-dashboard-app .figli-vbar-chart {
|
#figli-dashboard-app .figli-vbar-chart {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -213,6 +213,10 @@
|
|||||||
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 },
|
colorFor: { type: Function, default: null },
|
||||||
|
// Narrower label/value columns, smaller text -- for the per-année
|
||||||
|
// small-multiples grids, where a full-width chart wouldn't fit in
|
||||||
|
// a grid card.
|
||||||
|
compact: { type: Boolean, default: false },
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
maxAbs() {
|
maxAbs() {
|
||||||
@@ -225,7 +229,7 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
template:
|
template:
|
||||||
'<div class="figli-hbar-chart">' +
|
'<div class="figli-hbar-chart" :class="{\'is-compact\': compact}">' +
|
||||||
'<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">' +
|
||||||
@@ -425,6 +429,37 @@
|
|||||||
.map(([type, value]) => ({ label: TYPE_LABELS[type] || type, value, type }))
|
.map(([type, value]) => ({ label: TYPE_LABELS[type] || type, value, type }))
|
||||||
.sort((a, b) => b.value - a.value);
|
.sort((a, b) => b.value - a.value);
|
||||||
},
|
},
|
||||||
|
// Small multiples, one per year -- same source as typeItems() above
|
||||||
|
// (total_par_type_par_compte_par_annee is the same répartition-level
|
||||||
|
// SQL query, just also grouped by année, no extra request).
|
||||||
|
typeItemsParAnnee() {
|
||||||
|
if (!this.stats || !this.selectedCompte) return [];
|
||||||
|
return this.stats.annees.map((annee) => {
|
||||||
|
const parType = (this.stats.total_par_type_par_compte_par_annee[annee] || {})[this.selectedCompte] || {};
|
||||||
|
const items = Object.entries(parType)
|
||||||
|
.map(([type, value]) => ({ label: TYPE_LABELS[type] || type, value, type }))
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
return { annee, items };
|
||||||
|
}).filter((y) => y.items.length);
|
||||||
|
},
|
||||||
|
// Top 5 (not 10 like the all-time chart, above) -- one per year
|
||||||
|
// keeps the small-multiples grid readable.
|
||||||
|
topClientsParAnnee() {
|
||||||
|
if (!this.selectedCompte) return [];
|
||||||
|
return this.stats.annees.map((annee) => {
|
||||||
|
const parClient = new Map();
|
||||||
|
for (const r of this.entreesDuCompte) {
|
||||||
|
if ((r.date || '').slice(0, 4) !== annee) continue;
|
||||||
|
const client = r.client || '(sans client)';
|
||||||
|
parClient.set(client, (parClient.get(client) || 0) + r.parCompte[this.selectedCompte]);
|
||||||
|
}
|
||||||
|
const items = Array.from(parClient.entries())
|
||||||
|
.map(([label, value]) => ({ label, value: Math.round(value * 100) / 100 }))
|
||||||
|
.sort((a, b) => b.value - a.value)
|
||||||
|
.slice(0, 5);
|
||||||
|
return { annee, items };
|
||||||
|
}).filter((y) => y.items.length);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
formatEur(v) {
|
formatEur(v) {
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ class DashboardStatsController extends ControllerBase {
|
|||||||
$soldeParCompte = [];
|
$soldeParCompte = [];
|
||||||
$soldeParCompteParAnnee = [];
|
$soldeParCompteParAnnee = [];
|
||||||
$totalParTypeParCompte = [];
|
$totalParTypeParCompte = [];
|
||||||
|
$totalParTypeParCompteParAnnee = [];
|
||||||
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
|
||||||
@@ -144,6 +145,14 @@ class DashboardStatsController extends ControllerBase {
|
|||||||
// that year+compte would survive.
|
// that year+compte would survive.
|
||||||
$soldeParCompteParAnnee[$row->annee][$row->compte] =
|
$soldeParCompteParAnnee[$row->annee][$row->compte] =
|
||||||
round(($soldeParCompteParAnnee[$row->annee][$row->compte] ?? 0) + $total, 2);
|
round(($soldeParCompteParAnnee[$row->annee][$row->compte] ?? 0) + $total, 2);
|
||||||
|
|
||||||
|
// Per-année version of $totalParTypeParCompte above, for the
|
||||||
|
// "par année" small multiples on /dashboard/compte -- same rows,
|
||||||
|
// no extra query.
|
||||||
|
if ($row->type !== 'ouverture') {
|
||||||
|
$totalParTypeParCompteParAnnee[$row->annee][$row->compte][$row->type] =
|
||||||
|
($totalParTypeParCompteParAnnee[$row->annee][$row->compte][$row->type] ?? 0) + abs($total);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -160,6 +169,7 @@ class DashboardStatsController extends ControllerBase {
|
|||||||
// key order for soldeParCompteParAnnee follows first-seen compte per
|
// key order for soldeParCompteParAnnee follows first-seen compte per
|
||||||
// year, which can differ year to year.
|
// year, which can differ year to year.
|
||||||
ksort($soldeParCompteParAnnee);
|
ksort($soldeParCompteParAnnee);
|
||||||
|
ksort($totalParTypeParCompteParAnnee);
|
||||||
|
|
||||||
return new JsonResponse([
|
return new JsonResponse([
|
||||||
'annees' => $anneesList,
|
'annees' => $anneesList,
|
||||||
@@ -172,6 +182,13 @@ class DashboardStatsController extends ControllerBase {
|
|||||||
fn ($parType) => array_map(fn ($v) => round($v, 2), $parType),
|
fn ($parType) => array_map(fn ($v) => round($v, 2), $parType),
|
||||||
$totalParTypeParCompte
|
$totalParTypeParCompte
|
||||||
),
|
),
|
||||||
|
'total_par_type_par_compte_par_annee' => array_map(
|
||||||
|
fn ($parCompte) => array_map(
|
||||||
|
fn ($parType) => array_map(fn ($v) => round($v, 2), $parType),
|
||||||
|
$parCompte
|
||||||
|
),
|
||||||
|
$totalParTypeParCompteParAnnee
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
@@ -85,6 +85,9 @@
|
|||||||
<td class="is-negative">{{ formatEur(-r.residual) }}</td>
|
<td class="is-negative">{{ formatEur(-r.residual) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr><td colspan="5">Total</td><td class="is-negative">{{ formatEurRound(totalSurVerse) }}</td></tr>
|
||||||
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -103,6 +106,9 @@
|
|||||||
<td class="is-negative">{{ formatEur(r.parCompte[selectedCompte]) }}</td>
|
<td class="is-negative">{{ formatEur(r.parCompte[selectedCompte]) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr><td colspan="3">Total</td><td class="is-negative">{{ formatEurRound(-totalNonLies) }}</td></tr>
|
||||||
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -148,12 +154,24 @@
|
|||||||
<h2>Répartition de l'activité par type</h2>
|
<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>
|
<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>
|
<h-bar-chart :items="typeItems" :format-value="formatEurRound" :color-for="typeColor"></h-bar-chart>
|
||||||
|
<div class="figli-year-hbar-grid">
|
||||||
|
<div class="figli-year-hbar-card" v-for="y in typeItemsParAnnee" :key="y.annee">
|
||||||
|
<div class="figli-year-hbar-title">{{ y.annee }}</div>
|
||||||
|
<h-bar-chart :items="y.items" :format-value="formatEurRound" :color-for="typeColor" compact></h-bar-chart>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</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>
|
||||||
<h-bar-chart :items="topClientsItems" :format-value="formatEurRound"></h-bar-chart>
|
<h-bar-chart :items="topClientsItems" :format-value="formatEurRound"></h-bar-chart>
|
||||||
|
<div class="figli-year-hbar-grid">
|
||||||
|
<div class="figli-year-hbar-card" v-for="y in topClientsParAnnee" :key="y.annee">
|
||||||
|
<div class="figli-year-hbar-title">{{ y.annee }}</div>
|
||||||
|
<h-bar-chart :items="y.items" :format-value="formatEurRound" compact></h-bar-chart>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user