Rebuild /dashboard with charts, add Grand livre/Dashboard nav
Nav: a small "Grand livre" / "Dashboard" switcher, top-right on both
pages, with the current page highlighted. Needed hook_theme() to
declare current_route as an accepted variable for both theme hooks --
same class of bug as the earlier can_view_history fix: an undeclared
variable passed via the render array is silently dropped rather than
reaching Twig.
Dashboard: replaced the old "solde par compte / par client" tables
(computed client-side from a full unwindowed JSON:API fetch of every
node -- the same performance problem the /lignes sliding window was
built to avoid, just not yet felt at 1500+ lines) with a single
aggregate endpoint (DashboardStatsController, plain SQL GROUP BY) and
five chart panels: CA par année, solde par compte (diverging,
red/green), solde par compte trend (small multiples per compte),
répartition par type, top clients par CA. No charting library --
small dependency-free div/CSS bar charts (HBarChart/ColumnChart/
MiniTrend components in dashboard.js), consistent with this project
vendoring its own JS.
Two data-correctness fixes along the way: (1) the historical stray
mistyped dates (0213-06-15, 2015-08-29 -- preserved as-is per this
module's policy) needed excluding from per-year buckets without
excluding their money from all-time totals, so the filtering happens
per-output-field in PHP rather than as a blanket SQL date range. (2)
PHP silently casts numeric-looking array keys ("2021") to actual
integers, so array_keys() on a year-keyed map produces a mix of ints
and strings -- json_encode emits the int ones as bare JSON numbers in
a list (unlike object keys, which JSON always stringifies), which
broke the frontend's annees[i].slice(2) trend-card labels. Fixed with
an explicit array_map('strval', ...).
Verified: bar widths/colors match the underlying data exactly (e.g.
EXT.'s red bar is proportionally sized against Maud's green one per
their actual solde ratio), all 8 trend cards render correct year
labels, and both nav links correctly highlight on their own page.
This commit is contained in:
@@ -13,6 +13,45 @@
|
|||||||
margin-block-start: 0 !important;
|
margin-block-start: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Grand livre / Dashboard switcher, top-right of both pages. Only present
|
||||||
|
on those two custom routes, but attached here (already loaded on every
|
||||||
|
page for the phantom-top-bar fix above) rather than duplicated between
|
||||||
|
home.css and dashboard.css -- and defined with its own explicit colors
|
||||||
|
rather than relying on either page's #figli-*-app-scoped custom
|
||||||
|
properties, since this <nav> sits outside both of those elements. */
|
||||||
|
.figli-page-nav {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.5rem 0.25rem 0;
|
||||||
|
}
|
||||||
|
.figli-page-nav a {
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #6b7280;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.figli-page-nav a:hover {
|
||||||
|
border-color: #dcdee2;
|
||||||
|
}
|
||||||
|
.figli-page-nav a.is-active {
|
||||||
|
background: #eef1f6;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
html.gin--dark-mode .figli-page-nav a {
|
||||||
|
color: #a1a5ab;
|
||||||
|
}
|
||||||
|
html.gin--dark-mode .figli-page-nav a:hover {
|
||||||
|
border-color: #3d3e42;
|
||||||
|
}
|
||||||
|
html.gin--dark-mode .figli-page-nav a.is-active {
|
||||||
|
background: #2c2d30;
|
||||||
|
color: #e8e9ea;
|
||||||
|
}
|
||||||
|
|
||||||
/* Drupal status messages: fixed top-right, out of document flow, so they
|
/* Drupal status messages: fixed top-right, out of document flow, so they
|
||||||
never shift the page layout the way the default in-flow placement does
|
never shift the page layout the way the default in-flow placement does
|
||||||
-- and shrunk to a fraction of Gin's default size, which is sized for a
|
-- and shrunk to a fraction of Gin's default size, which is sized for a
|
||||||
|
|||||||
@@ -26,51 +26,18 @@ html.gin--dark-mode #figli-dashboard-app {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#figli-dashboard-app h2 {
|
#figli-dashboard-app h2 {
|
||||||
font-size: 1.25rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 2rem 0 0.75rem;
|
margin: 0 0 0.25rem;
|
||||||
color: var(--figli-text);
|
color: var(--figli-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
#figli-dashboard-app .figli-note {
|
#figli-dashboard-app .figli-note {
|
||||||
color: var(--figli-text-light);
|
color: var(--figli-text-light);
|
||||||
font-size: 0.875rem;
|
font-size: 0.8rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#figli-dashboard-app table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
background: var(--figli-bg);
|
|
||||||
color: var(--figli-text);
|
|
||||||
border: 1px solid var(--figli-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
#figli-dashboard-app th,
|
|
||||||
#figli-dashboard-app td {
|
|
||||||
text-align: left;
|
|
||||||
padding: 0.5rem 0.85rem;
|
|
||||||
border-bottom: 1px solid var(--figli-border);
|
|
||||||
color: var(--figli-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
#figli-dashboard-app th {
|
|
||||||
background: var(--figli-bg-alt);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#figli-dashboard-app td.amount {
|
|
||||||
text-align: right;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
#figli-dashboard-app tr.positive td.amount { color: var(--figli-positive); }
|
|
||||||
#figli-dashboard-app tr.negative td.amount { color: var(--figli-error); }
|
|
||||||
#figli-dashboard-app tr.total td { font-weight: 700; border-top: 2px solid var(--figli-border); }
|
|
||||||
|
|
||||||
#figli-dashboard-app .figli-error {
|
#figli-dashboard-app .figli-error {
|
||||||
background: #fde8e8;
|
background: #fde8e8;
|
||||||
border: 1px solid #f4a3a3;
|
border: 1px solid #f4a3a3;
|
||||||
@@ -78,3 +45,204 @@ html.gin--dark-mode #figli-dashboard-app {
|
|||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Summary cards --- */
|
||||||
|
#figli-dashboard-app .figli-summary-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-summary-card {
|
||||||
|
flex: 1 1 200px;
|
||||||
|
background: var(--figli-bg-alt);
|
||||||
|
border: 1px solid var(--figli-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-summary-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--figli-text-light);
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-summary-value {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-summary-value.is-positive { color: var(--figli-positive); }
|
||||||
|
#figli-dashboard-app .figli-summary-value.is-negative { color: var(--figli-error); }
|
||||||
|
|
||||||
|
/* --- Chart sections --- */
|
||||||
|
#figli-dashboard-app .figli-chart-section {
|
||||||
|
background: var(--figli-bg);
|
||||||
|
border: 1px solid var(--figli-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.25rem 1.5rem 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Horizontal bar chart (solde par compte, par type, top clients) --- */
|
||||||
|
#figli-dashboard-app .figli-hbar-chart {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-hbar-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 11rem 1fr 7rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-hbar-label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-hbar-track {
|
||||||
|
position: relative;
|
||||||
|
height: 0.9rem;
|
||||||
|
background: var(--figli-bg-alt);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-hbar-track.is-diverging {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-hbar-zero {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
bottom: -2px;
|
||||||
|
left: 50%;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--figli-border);
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-hbar-fill {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
min-width: 2px;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
#figli-dashboard-app .figli-hbar-fill {
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-hbar-value {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Vertical bar chart (CA par année) --- */
|
||||||
|
#figli-dashboard-app .figli-vbar-chart {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
height: 12rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-vbar-col {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-vbar-value {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-vbar-track {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-vbar-fill {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--figli-positive);
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
min-height: 2px;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
#figli-dashboard-app .figli-vbar-fill {
|
||||||
|
transition: height 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-vbar-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--figli-text-light);
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Trend grid (small multiples, one per compte) --- */
|
||||||
|
#figli-dashboard-app .figli-trend-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-trend-card {
|
||||||
|
background: var(--figli-bg-alt);
|
||||||
|
border: 1px solid var(--figli-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-trend-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-mini-trend {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.2rem;
|
||||||
|
height: 4.5rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-mini-bar-col {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-mini-bar-track {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-mini-bar-track::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--figli-border);
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-mini-bar-fill {
|
||||||
|
position: absolute;
|
||||||
|
left: 1px;
|
||||||
|
right: 1px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-mini-bar-fill.is-positive {
|
||||||
|
bottom: 50%;
|
||||||
|
background: var(--figli-positive);
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-mini-bar-fill.is-negative {
|
||||||
|
top: 50%;
|
||||||
|
background: var(--figli-error);
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-mini-bar-label {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--figli-text-light);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -202,11 +202,11 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
|
|||||||
function figli_compta_ledger_theme($existing, $type, $theme, $path) {
|
function figli_compta_ledger_theme($existing, $type, $theme, $path) {
|
||||||
return [
|
return [
|
||||||
'figli_compta_home' => [
|
'figli_compta_home' => [
|
||||||
'variables' => ['can_view_history' => FALSE],
|
'variables' => ['can_view_history' => FALSE, 'current_route' => NULL],
|
||||||
'template' => 'figli-compta-home',
|
'template' => 'figli-compta-home',
|
||||||
],
|
],
|
||||||
'figli_compta_dashboard' => [
|
'figli_compta_dashboard' => [
|
||||||
'variables' => [],
|
'variables' => ['current_route' => NULL],
|
||||||
'template' => 'figli-compta-dashboard',
|
'template' => 'figli-compta-dashboard',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ figli_compta_ledger.api_reconciliation_ouverture:
|
|||||||
requirements:
|
requirements:
|
||||||
_permission: 'access content'
|
_permission: 'access content'
|
||||||
|
|
||||||
|
figli_compta_ledger.api_dashboard_stats:
|
||||||
|
path: '/dashboard/api/stats'
|
||||||
|
defaults:
|
||||||
|
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardStatsController::stats'
|
||||||
|
requirements:
|
||||||
|
_permission: 'access content'
|
||||||
|
|
||||||
figli_compta_ledger.update_type:
|
figli_compta_ledger.update_type:
|
||||||
path: '/lignes/{node}/type'
|
path: '/lignes/{node}/type'
|
||||||
defaults:
|
defaults:
|
||||||
|
|||||||
@@ -1,107 +1,219 @@
|
|||||||
/**
|
/**
|
||||||
* @file
|
* @file
|
||||||
* Aggregate dashboard: solde par compte / solde par client, computed
|
* Dashboard: charts and aggregate totals (solde par compte, chiffre
|
||||||
* client-side from JSON:API. The line-by-line spreadsheet view is the
|
* d'affaires par année, répartition par type, top clients), computed
|
||||||
* site's home page (home.js), not this one.
|
* server-side (DashboardStatsController -- plain SQL GROUP BY, not Entity
|
||||||
|
* API) and rendered here as small dependency-free div/CSS bar charts. No
|
||||||
|
* charting library: this project vendors its own JS (see js/vendor/), and
|
||||||
|
* a handful of bar/line charts don't warrant pulling one in.
|
||||||
*/
|
*/
|
||||||
(function (Drupal, Vue) {
|
(function (Drupal, Vue) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const API_BASE = '/jsonapi/node/ligne_comptable';
|
|
||||||
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 TYPE_LABELS = {
|
||||||
|
entree: 'Entrée client',
|
||||||
|
charge: 'Charge structurelle',
|
||||||
|
versement: 'Versement freelance',
|
||||||
|
achat: 'Achat client',
|
||||||
|
hebergement: 'Hébergement',
|
||||||
|
autre: 'Autre',
|
||||||
|
};
|
||||||
|
// Stable colour per type, independent of sort order -- a viewer
|
||||||
|
// comparing this chart across page loads shouldn't see "achat" change
|
||||||
|
// colour just because its rank shifted.
|
||||||
|
const TYPE_COLORS = {
|
||||||
|
entree: '#1a7f37',
|
||||||
|
charge: '#6b7280',
|
||||||
|
versement: '#d97a0a',
|
||||||
|
achat: '#3b6fe0',
|
||||||
|
hebergement: '#0e9182',
|
||||||
|
autre: '#9061f0',
|
||||||
|
};
|
||||||
|
|
||||||
async function fetchAllLignes() {
|
async function fetchStats() {
|
||||||
// sort by nid: without an explicit, unique sort key, offset pagination
|
const res = await fetch('/dashboard/api/stats', { headers: { Accept: 'application/json' } });
|
||||||
// can silently duplicate or skip rows across pages.
|
if (!res.ok) throw new Error('/dashboard/api/stats a répondu ' + res.status);
|
||||||
let url = API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client&page[limit]=50&sort=drupal_internal__nid';
|
return res.json();
|
||||||
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;
|
|
||||||
}
|
|
||||||
// Defensive de-dup by node id, in case pagination ever repeats a row.
|
|
||||||
const seen = new Set();
|
|
||||||
const dedup = allData.filter((n) => (seen.has(n.id) ? false : (seen.add(n.id), true)));
|
|
||||||
return { data: dedup, includedMap };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolve(includedMap, ref) {
|
// Horizontal bar chart -- one row per item, label left, proportional
|
||||||
if (!ref) return null;
|
// bar, value right. Switches to a zero-centered "diverging" layout
|
||||||
return includedMap.get(ref.type + ':' + ref.id) || null;
|
// automatically when values can be negative (solde par compte), so a
|
||||||
|
// debit and a credit of the same magnitude read as mirror images
|
||||||
|
// instead of one dwarfing the other from a shared zero baseline.
|
||||||
|
const HBarChart = {
|
||||||
|
props: {
|
||||||
|
items: { type: Array, required: true },
|
||||||
|
formatValue: { type: Function, required: true },
|
||||||
|
colorFor: { type: Function, default: null },
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
hasNegative() {
|
||||||
|
return this.items.some((i) => i.value < 0);
|
||||||
|
},
|
||||||
|
maxAbs() {
|
||||||
|
return Math.max(1, ...this.items.map((i) => Math.abs(i.value)));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fillStyle(item) {
|
||||||
|
const pct = (Math.abs(item.value) / this.maxAbs) * 100;
|
||||||
|
if (this.hasNegative) {
|
||||||
|
return item.value >= 0
|
||||||
|
? { left: '50%', width: pct / 2 + '%' }
|
||||||
|
: { right: '50%', width: pct / 2 + '%' };
|
||||||
}
|
}
|
||||||
|
return { left: 0, width: pct + '%' };
|
||||||
|
},
|
||||||
|
fillColor(item) {
|
||||||
|
if (this.colorFor) return this.colorFor(item);
|
||||||
|
return item.value < 0 ? 'var(--figli-error)' : 'var(--figli-positive)';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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" :class="{\'is-diverging\': hasNegative}">' +
|
||||||
|
'<div class="figli-hbar-zero" v-if="hasNegative"></div>' +
|
||||||
|
'<div class="figli-hbar-fill" :style="[fillStyle(item), {background: fillColor(item)}]"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="figli-hbar-value">{{ formatValue(item.value) }}</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>',
|
||||||
|
};
|
||||||
|
|
||||||
function addTo(map, key, montant) {
|
// Vertical bar chart -- for a short time series (CA par année): a
|
||||||
if (!map.has(key)) map.set(key, { entrees: 0, sorties: 0 });
|
// handful of columns read left-to-right as a trend more naturally than
|
||||||
const row = map.get(key);
|
// horizontal bars would.
|
||||||
if (montant >= 0) row.entrees += montant;
|
const VBarChart = {
|
||||||
else row.sorties += montant;
|
props: {
|
||||||
}
|
items: { type: Array, required: true },
|
||||||
|
formatValue: { type: Function, required: true },
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
max() {
|
||||||
|
return Math.max(1, ...this.items.map((i) => i.value));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
barHeight(item) {
|
||||||
|
return Math.max(2, (item.value / this.max) * 100) + '%';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template:
|
||||||
|
'<div class="figli-vbar-chart">' +
|
||||||
|
'<div class="figli-vbar-col" v-for="item in items" :key="item.label">' +
|
||||||
|
'<div class="figli-vbar-value">{{ formatValue(item.value) }}</div>' +
|
||||||
|
'<div class="figli-vbar-track"><div class="figli-vbar-fill" :style="{height: barHeight(item)}"></div></div>' +
|
||||||
|
'<div class="figli-vbar-label">{{ item.label }}</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>',
|
||||||
|
};
|
||||||
|
|
||||||
function computeAggregations(data, includedMap) {
|
// Small multiples: one compact zero-centered bar-per-year trend per
|
||||||
const parComptes = new Map();
|
// compte, instead of a single 8-series line chart -- eight overlapping
|
||||||
const parClients = new Map();
|
// lines sharing one small area is hard to read; eight small independent
|
||||||
for (const node of data) {
|
// trends, each answering "is this person's balance growing or
|
||||||
const rels = node.relationships || {};
|
// shrinking", is not.
|
||||||
const clientTerm = resolve(includedMap, rels.field_client && rels.field_client.data);
|
const MiniTrend = {
|
||||||
const clientName = clientTerm ? clientTerm.attributes.name : '(sans client)';
|
props: {
|
||||||
const repartitionRefs = (rels.field_repartition && rels.field_repartition.data) || [];
|
annees: { type: Array, required: true },
|
||||||
for (const ref of repartitionRefs) {
|
values: { type: Array, required: true },
|
||||||
const paragraph = resolve(includedMap, ref);
|
formatValue: { type: Function, required: true },
|
||||||
if (!paragraph) continue;
|
},
|
||||||
const montant = parseFloat(paragraph.attributes.field_montant || 0);
|
computed: {
|
||||||
const compteTerm = resolve(includedMap, paragraph.relationships && paragraph.relationships.field_compte && paragraph.relationships.field_compte.data);
|
maxAbs() {
|
||||||
const compteName = compteTerm ? compteTerm.attributes.name : '(compte inconnu)';
|
return Math.max(1, ...this.values.filter((v) => v !== null).map((v) => Math.abs(v)));
|
||||||
addTo(parComptes, compteName, montant);
|
},
|
||||||
addTo(parClients, clientName, montant);
|
},
|
||||||
}
|
methods: {
|
||||||
}
|
barHeight(v) {
|
||||||
return { parComptes, parClients };
|
if (v === null) return '0%';
|
||||||
}
|
return Math.max(3, (Math.abs(v) / this.maxAbs) * 100) + '%';
|
||||||
|
},
|
||||||
function mapToRows(map) {
|
},
|
||||||
return Array.from(map.entries())
|
template:
|
||||||
.map(([name, v]) => ({ name, entrees: v.entrees, sorties: v.sorties, solde: v.entrees + v.sorties }))
|
'<div class="figli-mini-trend">' +
|
||||||
.sort((a, b) => a.solde - b.solde);
|
'<div class="figli-mini-bar-col" v-for="(v, i) in values" :key="annees[i]" :title="annees[i] + \' : \' + (v === null ? \'—\' : formatValue(v))">' +
|
||||||
}
|
'<div class="figli-mini-bar-track">' +
|
||||||
|
'<div class="figli-mini-bar-fill" :class="v !== null && v < 0 ? \'is-negative\' : \'is-positive\'" :style="{height: barHeight(v)}"></div>' +
|
||||||
function totalsOf(rows) {
|
'</div>' +
|
||||||
return rows.reduce(
|
'<div class="figli-mini-bar-label">{{ annees[i].slice(2) }}</div>' +
|
||||||
(acc, r) => ({ entrees: acc.entrees + r.entrees, sorties: acc.sorties + r.sorties, solde: acc.solde + r.solde }),
|
'</div>' +
|
||||||
{ entrees: 0, sorties: 0, solde: 0 }
|
'</div>',
|
||||||
);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const App = {
|
const App = {
|
||||||
|
components: { HBarChart, ColumnChart: VBarChart, MiniTrend },
|
||||||
data() {
|
data() {
|
||||||
return { loading: true, error: null, tables: [], lineCount: 0 };
|
return { loading: true, error: null, stats: null };
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
caParAnneeItems() {
|
||||||
|
if (!this.stats) return [];
|
||||||
|
return this.stats.annees.map((y) => ({ label: y, value: this.stats.ca_par_annee[y] || 0 }));
|
||||||
|
},
|
||||||
|
soldeParCompteItems() {
|
||||||
|
if (!this.stats) return [];
|
||||||
|
return Object.entries(this.stats.solde_par_compte)
|
||||||
|
.map(([label, value]) => ({ label, value }))
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
},
|
||||||
|
typeItems() {
|
||||||
|
if (!this.stats) return [];
|
||||||
|
return Object.entries(this.stats.total_par_type)
|
||||||
|
.map(([type, value]) => ({ label: TYPE_LABELS[type] || type, value, type }))
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
},
|
||||||
|
topClientsItems() {
|
||||||
|
if (!this.stats) return [];
|
||||||
|
return this.stats.top_clients.map((c) => ({ label: c.client, value: c.ca }));
|
||||||
|
},
|
||||||
|
// Comptes ordered by all-time solde (richest first) -- same order
|
||||||
|
// as soldeParCompteItems, so the trend grid below reads as a
|
||||||
|
// continuation of the bar chart above it rather than an unrelated
|
||||||
|
// shuffle.
|
||||||
|
comptesOrdonnes() {
|
||||||
|
return this.soldeParCompteItems.map((i) => i.label);
|
||||||
|
},
|
||||||
|
totalCA() {
|
||||||
|
if (!this.stats) return 0;
|
||||||
|
return Object.values(this.stats.ca_par_annee).reduce((a, b) => a + b, 0);
|
||||||
|
},
|
||||||
|
caAnneeEnCours() {
|
||||||
|
if (!this.stats || !this.stats.annees.length) return null;
|
||||||
|
const derniere = this.stats.annees[this.stats.annees.length - 1];
|
||||||
|
return { annee: derniere, value: this.stats.ca_par_annee[derniere] || 0 };
|
||||||
|
},
|
||||||
|
totalActivite() {
|
||||||
|
if (!this.stats) return 0;
|
||||||
|
return Object.values(this.stats.total_par_type).reduce((a, b) => a + b, 0);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
formatEur(v) {
|
formatEur(v) {
|
||||||
return EUR.format(v);
|
return EUR.format(v);
|
||||||
},
|
},
|
||||||
rowClass(solde) {
|
formatEurRound(v) {
|
||||||
if (solde > 0.5) return 'positive';
|
return EUR_ROUND.format(v);
|
||||||
if (solde < -0.5) return 'negative';
|
},
|
||||||
return '';
|
trendValues(compte) {
|
||||||
|
return this.stats.annees.map((y) => {
|
||||||
|
const parAnnee = this.stats.solde_par_compte_par_annee[y];
|
||||||
|
return parAnnee && parAnnee[compte] !== undefined ? parAnnee[compte] : null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
typeColor(item) {
|
||||||
|
return TYPE_COLORS[item.type] || '#6b7280';
|
||||||
},
|
},
|
||||||
async load() {
|
async load() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
try {
|
try {
|
||||||
const { data, includedMap } = await fetchAllLignes();
|
this.stats = await fetchStats();
|
||||||
this.lineCount = data.length;
|
|
||||||
const { parComptes, parClients } = computeAggregations(data, includedMap);
|
|
||||||
const comptesRows = mapToRows(parComptes);
|
|
||||||
const clientsRows = mapToRows(parClients);
|
|
||||||
this.tables = [
|
|
||||||
{ title: 'Solde par compte', note: this.lineCount + ' lignes comptables chargées.', rows: comptesRows, totals: totalsOf(comptesRows) },
|
|
||||||
{ title: 'Solde par client', note: 'Entrées créditées par client vs. montants sortis (versements, achats, charges) sur les lignes rattachées à ce client.', rows: clientsRows, totals: totalsOf(clientsRows) },
|
|
||||||
];
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.error = err.message;
|
this.error = err.message;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class DashboardController extends ControllerBase {
|
|||||||
return [
|
return [
|
||||||
'#theme' => 'figli_compta_home',
|
'#theme' => 'figli_compta_home',
|
||||||
'#can_view_history' => $this->currentUser()->hasPermission('view ligne_comptable revisions'),
|
'#can_view_history' => $this->currentUser()->hasPermission('view ligne_comptable revisions'),
|
||||||
|
'#current_route' => 'figli_compta_ledger.home',
|
||||||
'#attached' => [
|
'#attached' => [
|
||||||
'library' => ['figli_compta_ledger/home'],
|
'library' => ['figli_compta_ledger/home'],
|
||||||
],
|
],
|
||||||
@@ -25,11 +26,13 @@ class DashboardController extends ControllerBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Secondary page: aggregate solde par compte / par client.
|
* Secondary page: charts and aggregate totals (par compte, par année,
|
||||||
|
* par client, par type).
|
||||||
*/
|
*/
|
||||||
public function view() {
|
public function view() {
|
||||||
return [
|
return [
|
||||||
'#theme' => 'figli_compta_dashboard',
|
'#theme' => 'figli_compta_dashboard',
|
||||||
|
'#current_route' => 'figli_compta_ledger.dashboard',
|
||||||
'#attached' => [
|
'#attached' => [
|
||||||
'library' => ['figli_compta_ledger/dashboard'],
|
'library' => ['figli_compta_ledger/dashboard'],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\figli_compta_ledger\Controller;
|
||||||
|
|
||||||
|
use Drupal\Core\Controller\ControllerBase;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single aggregate endpoint backing the charts on /dashboard. Plain SQL
|
||||||
|
* (Database API), not Entity API -- with 1500+ ligne_comptable nodes,
|
||||||
|
* loading full entities the way the old dashboard.js did (fetch every node
|
||||||
|
* via JSON:API, aggregate client-side) is the exact performance problem
|
||||||
|
* the /lignes sliding window was built to avoid; a handful of GROUP BY
|
||||||
|
* queries answers every chart in one page load instead.
|
||||||
|
*/
|
||||||
|
class DashboardStatsController extends ControllerBase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same reasoning as home.js's MIN_LOADABLE_DATE/MAX_LOADABLE_DATE:
|
||||||
|
* comfortably before the earliest migrated year (2021) and well past
|
||||||
|
* any plausible future-dated entry, wide enough to never need updating.
|
||||||
|
*/
|
||||||
|
const MIN_ANNEE = '2020';
|
||||||
|
const MAX_ANNEE = '2030';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a year is real enough to appear in a per-year chart -- see
|
||||||
|
* MIN_ANNEE/MAX_ANNEE. Doesn't affect all-time totals, which count
|
||||||
|
* every line regardless of its date.
|
||||||
|
*/
|
||||||
|
private function isAnneeValide(string $annee): bool {
|
||||||
|
return $annee >= self::MIN_ANNEE && $annee < self::MAX_ANNEE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /dashboard/api/stats.
|
||||||
|
*/
|
||||||
|
public function stats() {
|
||||||
|
$connection = \Drupal::database();
|
||||||
|
|
||||||
|
// Node-level aggregate: one row per ligne_comptable (annee, type,
|
||||||
|
// client, montant_ht) -- backs chiffre d'affaires, per-type, and
|
||||||
|
// per-client breakdowns. Joining field_repartition here would
|
||||||
|
// multiply each node by its répartition row count and inflate
|
||||||
|
// montant_ht sums, so it's deliberately kept separate from the
|
||||||
|
// répartition-level query below.
|
||||||
|
$nodeQuery = $connection->select('node__field_date_ligne', 'd');
|
||||||
|
$nodeQuery->innerJoin('node__field_type_ligne', 't', 't.entity_id = d.entity_id');
|
||||||
|
$nodeQuery->leftJoin('node__field_client', 'ncl', 'ncl.entity_id = d.entity_id');
|
||||||
|
$nodeQuery->leftJoin('taxonomy_term_field_data', 'cl', 'cl.tid = ncl.field_client_target_id');
|
||||||
|
$nodeQuery->leftJoin('node__field_montant_ht', 'mh', 'mh.entity_id = d.entity_id');
|
||||||
|
$nodeQuery->condition('d.bundle', 'ligne_comptable');
|
||||||
|
$nodeQuery->addExpression('SUBSTRING(d.field_date_ligne_value, 1, 4)', 'annee');
|
||||||
|
$nodeQuery->addField('t', 'field_type_ligne_value', 'type');
|
||||||
|
$nodeQuery->addField('cl', 'name', 'client');
|
||||||
|
$nodeQuery->addField('mh', 'field_montant_ht_value', 'montant_ht');
|
||||||
|
$nodeRows = $nodeQuery->execute()->fetchAll();
|
||||||
|
|
||||||
|
// Répartition-level aggregate: one row per (node, compte) répartition
|
||||||
|
// share, pre-summed per (annee, compte) in SQL -- backs solde par
|
||||||
|
// compte, both all-time and per-year (each year's own total already
|
||||||
|
// includes that year's ouverture line, so it *is* that year's closing
|
||||||
|
// balance -- same logic as LedgerStatsController::totauxAnnee()).
|
||||||
|
$compteQuery = $connection->select('node__field_date_ligne', 'd');
|
||||||
|
$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_compte', 'c', 'c.entity_id = r.field_repartition_target_id');
|
||||||
|
$compteQuery->innerJoin('taxonomy_term_field_data', 'tc', 'tc.tid = c.field_compte_target_id');
|
||||||
|
$compteQuery->condition('d.bundle', 'ligne_comptable');
|
||||||
|
$compteQuery->addExpression('SUBSTRING(d.field_date_ligne_value, 1, 4)', 'annee');
|
||||||
|
$compteQuery->addField('tc', 'name', 'compte');
|
||||||
|
$compteQuery->addExpression('SUM(m.field_montant_value)', 'total');
|
||||||
|
$compteQuery->groupBy('annee');
|
||||||
|
$compteQuery->groupBy('compte');
|
||||||
|
$compteRows = $compteQuery->execute()->fetchAll();
|
||||||
|
|
||||||
|
// --- Aggregate the node-level rows in PHP. ---
|
||||||
|
$caParAnnee = [];
|
||||||
|
$totalParType = [];
|
||||||
|
$caParClient = [];
|
||||||
|
$annees = [];
|
||||||
|
foreach ($nodeRows as $row) {
|
||||||
|
$montant = $row->montant_ht !== NULL ? (float) $row->montant_ht : 0.0;
|
||||||
|
|
||||||
|
// All-time totals (type breakdown, client ranking) count every line
|
||||||
|
// regardless of date -- a mistyped date doesn't make the money any
|
||||||
|
// less real. Only the per-year buckets below need a sane year.
|
||||||
|
if ($row->type !== 'ouverture') {
|
||||||
|
$totalParType[$row->type] = ($totalParType[$row->type] ?? 0) + abs($montant);
|
||||||
|
}
|
||||||
|
if ($row->type === 'entree') {
|
||||||
|
$client = $row->client ?: '(sans client)';
|
||||||
|
$caParClient[$client] = ($caParClient[$client] ?? 0) + $montant;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->isAnneeValide($row->annee)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$annees[$row->annee] = TRUE;
|
||||||
|
if ($row->type === 'entree') {
|
||||||
|
$caParAnnee[$row->annee] = ($caParAnnee[$row->annee] ?? 0) + $montant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
arsort($caParClient);
|
||||||
|
$topClients = [];
|
||||||
|
$i = 0;
|
||||||
|
foreach ($caParClient as $client => $total) {
|
||||||
|
if ($i++ >= 12) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$topClients[] = ['client' => $client, 'ca' => round($total, 2)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Aggregate the répartition-level rows in PHP. ---
|
||||||
|
$soldeParCompte = [];
|
||||||
|
$soldeParCompteParAnnee = [];
|
||||||
|
foreach ($compteRows as $row) {
|
||||||
|
$total = (float) $row->total;
|
||||||
|
// Same reasoning: the all-time balance includes every line; the
|
||||||
|
// per-year trend only makes sense for a real year.
|
||||||
|
$soldeParCompte[$row->compte] = ($soldeParCompte[$row->compte] ?? 0) + $total;
|
||||||
|
if (!$this->isAnneeValide($row->annee)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$annees[$row->annee] = TRUE;
|
||||||
|
$soldeParCompteParAnnee[$row->annee][$row->compte] = round($total, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// array_keys() alone would leak PHP's array-key int-casting here: a
|
||||||
|
// key that looks like a canonical integer ("2021") is silently stored
|
||||||
|
// as an int, not a string, and json_encode() then emits it as a bare
|
||||||
|
// JSON number in this *list* -- unlike object keys (ca_par_annee,
|
||||||
|
// solde_par_compte_par_annee below), which JSON always stringifies
|
||||||
|
// regardless of the PHP source type. The frontend expects every year
|
||||||
|
// as a string throughout, so cast explicitly.
|
||||||
|
$anneesList = array_map('strval', array_keys($annees));
|
||||||
|
sort($anneesList);
|
||||||
|
|
||||||
|
// Chronological order per year, not insertion order -- PHP's array
|
||||||
|
// key order for soldeParCompteParAnnee follows first-seen compte per
|
||||||
|
// year, which can differ year to year.
|
||||||
|
ksort($soldeParCompteParAnnee);
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'annees' => $anneesList,
|
||||||
|
'ca_par_annee' => array_map(fn ($v) => round($v, 2), $caParAnnee),
|
||||||
|
'total_par_type' => array_map(fn ($v) => round($v, 2), $totalParType),
|
||||||
|
'top_clients' => $topClients,
|
||||||
|
'solde_par_compte' => array_map(fn ($v) => round($v, 2), $soldeParCompte),
|
||||||
|
'solde_par_compte_par_annee' => $soldeParCompteParAnnee,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,36 +1,69 @@
|
|||||||
{#
|
{#
|
||||||
Aggregate view: solde par compte / solde par client. The spreadsheet-like
|
Charts and aggregate totals: solde par compte, chiffre d'affaires par
|
||||||
|
année, répartition par type, top clients. The spreadsheet-like
|
||||||
line-by-line view is the site's home page (figli-compta-home.html.twig).
|
line-by-line view is the site's home page (figli-compta-home.html.twig).
|
||||||
|
|
||||||
{% verbatim %} below: this is Vue template syntax, not Twig -- both use
|
{% verbatim %} below: this is Vue template syntax, not Twig -- both use
|
||||||
{{ }}, so verbatim tells Twig to leave it alone and let Vue compile it
|
{{ }}, so verbatim tells Twig to leave it alone and let Vue compile it
|
||||||
in the browser.
|
in the browser.
|
||||||
#}
|
#}
|
||||||
|
<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>
|
||||||
|
</nav>
|
||||||
{% verbatim %}
|
{% verbatim %}
|
||||||
<div id="figli-dashboard-app">
|
<div id="figli-dashboard-app">
|
||||||
<p v-if="loading">Chargement des données…</p>
|
<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>
|
<p v-else-if="error" class="figli-error">Erreur de chargement du tableau de bord : {{ error }}</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<section v-for="table in tables" :key="table.title">
|
<div class="figli-summary-row">
|
||||||
<h2>{{ table.title }}</h2>
|
<div class="figli-summary-card">
|
||||||
<p class="figli-note" v-if="table.note">{{ table.note }}</p>
|
<div class="figli-summary-label">Chiffre d'affaires total</div>
|
||||||
<table>
|
<div class="figli-summary-value">{{ formatEurRound(totalCA) }}</div>
|
||||||
<thead><tr><th></th><th>Entrées (+)</th><th>Sorties (-)</th><th>Solde</th></tr></thead>
|
</div>
|
||||||
<tbody>
|
<div class="figli-summary-card" v-if="caAnneeEnCours">
|
||||||
<tr v-for="row in table.rows" :key="row.name" :class="rowClass(row.solde)">
|
<div class="figli-summary-label">CA {{ caAnneeEnCours.annee }}</div>
|
||||||
<td>{{ row.name }}</td>
|
<div class="figli-summary-value">{{ formatEurRound(caAnneeEnCours.value) }}</div>
|
||||||
<td class="amount">{{ formatEur(row.entrees) }}</td>
|
</div>
|
||||||
<td class="amount">{{ formatEur(row.sorties) }}</td>
|
<div class="figli-summary-card" v-for="item in soldeParCompteItems.slice(0, 1)" :key="'top-' + item.label">
|
||||||
<td class="amount">{{ formatEur(row.solde) }}</td>
|
<div class="figli-summary-label">Meilleur solde</div>
|
||||||
</tr>
|
<div class="figli-summary-value" :class="item.value < 0 ? 'is-negative' : 'is-positive'">{{ item.label }} · {{ formatEurRound(item.value) }}</div>
|
||||||
<tr class="total">
|
</div>
|
||||||
<td>TOTAL</td>
|
</div>
|
||||||
<td class="amount">{{ formatEur(table.totals.entrees) }}</td>
|
|
||||||
<td class="amount">{{ formatEur(table.totals.sorties) }}</td>
|
<section class="figli-chart-section">
|
||||||
<td class="amount">{{ formatEur(table.totals.solde) }}</td>
|
<h2>Chiffre d'affaires par année</h2>
|
||||||
</tr>
|
<p class="figli-note">Total des entrées client (montant HT) facturées chaque année.</p>
|
||||||
</tbody>
|
<column-chart :items="caParAnneeItems" :format-value="formatEurRound"></column-chart>
|
||||||
</table>
|
</section>
|
||||||
|
|
||||||
|
<section class="figli-chart-section">
|
||||||
|
<h2>Solde par compte</h2>
|
||||||
|
<p class="figli-note">Solde cumulé de chaque compte depuis l'origine (ouverture comprise) -- vert = créditeur, rouge = débiteur.</p>
|
||||||
|
<h-bar-chart :items="soldeParCompteItems" :format-value="formatEurRound"></h-bar-chart>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="figli-chart-section">
|
||||||
|
<h2>Évolution du solde par compte</h2>
|
||||||
|
<p class="figli-note">Solde de clôture de chaque compte, année par année.</p>
|
||||||
|
<div class="figli-trend-grid">
|
||||||
|
<div class="figli-trend-card" v-for="compte in comptesOrdonnes" :key="compte">
|
||||||
|
<div class="figli-trend-title">{{ compte }}</div>
|
||||||
|
<mini-trend :annees="stats.annees" :values="trendValues(compte)" :format-value="formatEurRound"></mini-trend>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="figli-chart-section">
|
||||||
|
<h2>Répartition de l'activité par type</h2>
|
||||||
|
<p class="figli-note">Montant total (HT, valeur absolue) par type de ligne, hors ouvertures.</p>
|
||||||
|
<h-bar-chart :items="typeItems" :format-value="formatEurRound" :color-for="typeColor"></h-bar-chart>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="figli-chart-section">
|
||||||
|
<h2>Top clients par chiffre d'affaires</h2>
|
||||||
|
<p class="figli-note">Les 12 clients ayant généré le plus de chiffre d'affaires, toutes années confondues.</p>
|
||||||
|
<h-bar-chart :items="topClientsItems" :format-value="formatEurRound"></h-bar-chart>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
"Ajouter une ligne" opens the real Drupal node form in a modal
|
"Ajouter une ligne" opens the real Drupal node form in a modal
|
||||||
(core/drupal.dialog.ajax) -- no form logic duplicated in JS.
|
(core/drupal.dialog.ajax) -- no form logic duplicated in JS.
|
||||||
#}
|
#}
|
||||||
|
<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>
|
||||||
|
</nav>
|
||||||
{% verbatim %}
|
{% verbatim %}
|
||||||
<div id="figli-home-app">
|
<div id="figli-home-app">
|
||||||
<div class="figli-toolbar">
|
<div class="figli-toolbar">
|
||||||
|
|||||||
Reference in New Issue
Block a user