Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3688bddba8 | ||
|
|
af36591d70 | ||
|
|
74d8eb2aa2 | ||
|
|
96bd9502d2 | ||
|
|
cb2a0fcdaa | ||
|
|
612269ec6d | ||
|
|
d17a3e2e33 | ||
|
|
692f7f3ea3 | ||
|
|
8dfb4af98a | ||
|
|
43817dcdce | ||
|
|
3552b5a79d | ||
|
|
c927771795 |
@@ -69,7 +69,14 @@ html.gin--dark-mode .figli-page-nav a.is-active {
|
||||
([data-drupal-messages-fallback], used when Drupal.Message.add() -- our
|
||||
own MessageCommand-driven AJAX messages included -- has no region to
|
||||
attach to). Auto-dismiss timing for non-error messages is handled in
|
||||
admin-chrome.js. */
|
||||
admin-chrome.js.
|
||||
z-index 100000: the region is lifted to a direct <body> child by
|
||||
admin-chrome.js (Gin's layout stacking contexts would otherwise bury
|
||||
it under the modal overlay), and 100000 puts it above the jQuery UI
|
||||
dialog itself (~100, .ui-front) and its overlay (dialog - 1) -- and
|
||||
unreachable: jQuery UI only ever raises a dialog above .ui-front
|
||||
siblings (_moveToTop), which the messages wrapper is not. Messages
|
||||
stay readable on top of everything while a modal is open. */
|
||||
[data-drupal-messages],
|
||||
[data-drupal-messages-fallback] {
|
||||
position: fixed !important;
|
||||
@@ -78,7 +85,7 @@ html.gin--dark-mode .figli-page-nav a.is-active {
|
||||
left: auto !important;
|
||||
width: auto;
|
||||
max-width: 22rem;
|
||||
z-index: 1000;
|
||||
z-index: 100000;
|
||||
}
|
||||
[data-drupal-messages] .messages-list__wrapper,
|
||||
[data-drupal-messages] .messages__wrapper,
|
||||
|
||||
@@ -364,6 +364,25 @@ html.gin--dark-mode #figli-dashboard-app {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* Total line under an h-bar-chart (currently just Charges structurelles
|
||||
par client, main chart and each per-année card) -- separate from the
|
||||
chart component itself, plain right-aligned text matching
|
||||
.figli-hbar-value's alignment/tabular-nums so the total lines up
|
||||
visually with the bars' own value column above it. */
|
||||
#figli-dashboard-app .figli-chart-total {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--figli-border);
|
||||
text-align: right;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
#figli-dashboard-app .figli-chart-total.is-compact {
|
||||
margin-top: 0.4rem;
|
||||
padding-top: 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
/* --- Year small multiples (par année, next to the all-time chart) --- */
|
||||
#figli-dashboard-app .figli-year-hbar-grid {
|
||||
display: grid;
|
||||
|
||||
@@ -25,6 +25,16 @@ dashboard:
|
||||
- core/drupal
|
||||
- figli_compta_ledger/vue
|
||||
|
||||
dashboard_repartition:
|
||||
js:
|
||||
js/dashboard-repartition.js: {}
|
||||
css:
|
||||
theme:
|
||||
css/dashboard.css: {}
|
||||
dependencies:
|
||||
- core/drupal
|
||||
- figli_compta_ledger/vue
|
||||
|
||||
dashboard_compte:
|
||||
js:
|
||||
js/dashboard-compte.js: {}
|
||||
|
||||
@@ -481,6 +481,10 @@ function figli_compta_ledger_theme($existing, $type, $theme, $path) {
|
||||
'variables' => ['current_route' => NULL],
|
||||
'template' => 'figli-compta-dashboard',
|
||||
],
|
||||
'figli_compta_dashboard_repartition' => [
|
||||
'variables' => ['current_route' => NULL],
|
||||
'template' => 'figli-compta-dashboard-repartition',
|
||||
],
|
||||
'figli_compta_dashboard_compte' => [
|
||||
'variables' => ['current_route' => NULL],
|
||||
'template' => 'figli-compta-dashboard-compte',
|
||||
@@ -503,6 +507,7 @@ function figli_compta_ledger_page_attachments(array &$attachments) {
|
||||
$front_end_routes = [
|
||||
'figli_compta_ledger.home',
|
||||
'figli_compta_ledger.dashboard',
|
||||
'figli_compta_ledger.dashboard_repartition',
|
||||
'figli_compta_ledger.dashboard_compte',
|
||||
'figli_compta_ledger.history',
|
||||
'figli_compta_ledger.link_entree',
|
||||
|
||||
@@ -14,6 +14,14 @@ figli_compta_ledger.dashboard:
|
||||
requirements:
|
||||
_permission: 'access content'
|
||||
|
||||
figli_compta_ledger.dashboard_repartition:
|
||||
path: '/dashboard/repartition'
|
||||
defaults:
|
||||
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::repartitionView'
|
||||
_title: 'Répartition / Soldes - SAS Figures Libres'
|
||||
requirements:
|
||||
_permission: 'access content'
|
||||
|
||||
figli_compta_ledger.dashboard_compte:
|
||||
path: '/dashboard/compte'
|
||||
defaults:
|
||||
@@ -42,13 +50,6 @@ figli_compta_ledger.link_entree:
|
||||
node:
|
||||
type: entity:node
|
||||
|
||||
figli_compta_ledger.api_totaux_annee:
|
||||
path: '/lignes/api/totaux'
|
||||
defaults:
|
||||
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::totauxAnnee'
|
||||
requirements:
|
||||
_permission: 'access content'
|
||||
|
||||
figli_compta_ledger.api_annees:
|
||||
path: '/lignes/api/annees'
|
||||
defaults:
|
||||
|
||||
@@ -39,7 +39,32 @@
|
||||
root.querySelectorAll('.messages-list__item').forEach(handleMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifts the [data-drupal-messages] region to a direct <body> child.
|
||||
*
|
||||
* Gin renders it deep inside its layout (main.page-content > region
|
||||
* highlighted), where ancestor stacking contexts neutralize the
|
||||
* region's position:fixed + high z-index (admin-chrome.css): the whole
|
||||
* subtree then stacks below the jQuery UI modal overlay -- a direct
|
||||
* <body> child -- which is why messages inserted while a modal is open
|
||||
* (MessageCommand-driven, e.g. the répartition-sum error on the
|
||||
* ligne_comptable modal form) rendered unreadably *under* the overlay.
|
||||
* As a <body> child, the region's z-index competes directly with the
|
||||
* overlay/dialog and wins. Core itself puts the fallback wrapper at
|
||||
* body level (Drupal.Message.defaultWrapper(), misc/message.js), so
|
||||
* this is also where messages already land on a page without the
|
||||
* region; MessageCommand re-queries the region at response time, so
|
||||
* moving it after page load breaks no insertion path.
|
||||
*/
|
||||
function liftMessages() {
|
||||
var region = document.querySelector('[data-drupal-messages]');
|
||||
if (region && region.parentElement !== document.body) {
|
||||
document.body.appendChild(region);
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
liftMessages();
|
||||
scan(document);
|
||||
new MutationObserver(function (mutations) {
|
||||
mutations.forEach(function (mutation) {
|
||||
@@ -48,6 +73,16 @@
|
||||
if (node.classList && node.classList.contains('messages-list__item')) {
|
||||
handleMessage(node);
|
||||
}
|
||||
// A modal (or a re-rendered messages region) entering the DOM:
|
||||
// re-check the lift right when it matters -- the region must
|
||||
// already be body-level when the dialog's overlay and any
|
||||
// MessageCommand insertions show up. matches() first because
|
||||
// querySelector() never matches the node itself. liftMessages()
|
||||
// is idempotent, so over-triggering is harmless.
|
||||
if (node.matches('.ui-dialog, [data-drupal-messages]')
|
||||
|| node.querySelector('.ui-dialog, [data-drupal-messages]')) {
|
||||
liftMessages();
|
||||
}
|
||||
if (node.querySelectorAll) {
|
||||
scan(node);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @file
|
||||
* "Répartition / Soldes": solde par compte (all-time bar chart + one
|
||||
* year-by-year trend per compte), split out of the general /dashboard so
|
||||
* that page stays focused on activity/CA/type/client breakdowns. Same
|
||||
* /dashboard/api/stats endpoint as dashboard.js/dashboard-compte.js
|
||||
* (DashboardStatsController -- plain SQL GROUP BY, not Entity API), no
|
||||
* new backend needed -- solde_par_compte/solde_par_compte_par_annee were
|
||||
* already in that response, just unused on this page until now.
|
||||
*
|
||||
* HBarChart/MiniTrend are duplicated from dashboard.js rather than
|
||||
* shared, same established convention as dashboard-compte.js's own
|
||||
* copies (see dashboard.css's comment on the Signalement filter rules).
|
||||
* Root element id is deliberately the same #figli-dashboard-app as the
|
||||
* other two dashboard pages (not a unique id) -- dashboard.css scopes
|
||||
* its CSS custom properties (--figli-bg, dark-mode overrides, etc.) to
|
||||
* that selector, and reusing it is how all three dashboard pages pick
|
||||
* those up without a separate stylesheet.
|
||||
*/
|
||||
(function (Drupal, Vue) {
|
||||
'use strict';
|
||||
|
||||
const EUR_ROUND = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 });
|
||||
|
||||
async function fetchStats() {
|
||||
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();
|
||||
}
|
||||
|
||||
// Horizontal bar chart -- same shape as dashboard.js's own copy,
|
||||
// including the zero-centered "diverging" layout for negative values
|
||||
// (a compte's solde can be a débit).
|
||||
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>',
|
||||
};
|
||||
|
||||
// Small multiples: one compact zero-centered bar-per-year trend per
|
||||
// compte, instead of a single 8-series line chart -- same reasoning
|
||||
// and same markup as dashboard.js's own copy.
|
||||
const MiniTrend = {
|
||||
props: {
|
||||
annees: { type: Array, required: true },
|
||||
values: { type: Array, required: true },
|
||||
formatValue: { type: Function, required: true },
|
||||
},
|
||||
computed: {
|
||||
maxAbs() {
|
||||
return Math.max(1, ...this.values.filter((v) => v !== null).map((v) => Math.abs(v)));
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
barHeight(v) {
|
||||
if (v === null) return '0%';
|
||||
return Math.max(3, (Math.abs(v) / this.maxAbs) * 100) + '%';
|
||||
},
|
||||
},
|
||||
template:
|
||||
'<div class="figli-mini-trend">' +
|
||||
'<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>' +
|
||||
'</div>' +
|
||||
'<div class="figli-mini-bar-label">{{ annees[i].slice(2) }}</div>' +
|
||||
'</div>' +
|
||||
'</div>',
|
||||
};
|
||||
|
||||
const App = {
|
||||
components: { HBarChart, MiniTrend },
|
||||
data() {
|
||||
return { loading: true, error: null, stats: null };
|
||||
},
|
||||
computed: {
|
||||
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);
|
||||
},
|
||||
// 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);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
formatEurRound(v) {
|
||||
return EUR_ROUND.format(v);
|
||||
},
|
||||
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;
|
||||
});
|
||||
},
|
||||
async load() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
this.stats = await fetchStats();
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.load();
|
||||
},
|
||||
};
|
||||
|
||||
Drupal.behaviors.figliComptaDashboardRepartition = {
|
||||
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);
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* @file
|
||||
* Dashboard: charts and aggregate totals (solde par compte, chiffre
|
||||
* d'affaires par année, répartition par type, top clients), computed
|
||||
* 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.
|
||||
* Dashboard: charts and aggregate totals (chiffre d'affaires par année,
|
||||
* répartition par type, top clients), computed 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. Solde par
|
||||
* compte lives on its own page (js/dashboard-repartition.js).
|
||||
*/
|
||||
(function (Drupal, Vue) {
|
||||
'use strict';
|
||||
@@ -54,6 +55,12 @@
|
||||
items: { type: Array, required: true },
|
||||
formatValue: { type: Function, required: true },
|
||||
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. Same prop as dashboard-compte.js's own HBarChart
|
||||
// copy (this project duplicates the component rather than sharing
|
||||
// it between dashboard.js/dashboard-compte.js, see dashboard.css).
|
||||
compact: { type: Boolean, default: false },
|
||||
},
|
||||
computed: {
|
||||
hasNegative() {
|
||||
@@ -79,7 +86,7 @@
|
||||
},
|
||||
},
|
||||
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-label" :title="item.label">{{ item.label }}</div>' +
|
||||
'<div class="figli-hbar-track" :class="{\'is-diverging\': hasNegative}">' +
|
||||
@@ -119,41 +126,8 @@
|
||||
'</div>',
|
||||
};
|
||||
|
||||
// Small multiples: one compact zero-centered bar-per-year trend per
|
||||
// compte, instead of a single 8-series line chart -- eight overlapping
|
||||
// lines sharing one small area is hard to read; eight small independent
|
||||
// trends, each answering "is this person's balance growing or
|
||||
// shrinking", is not.
|
||||
const MiniTrend = {
|
||||
props: {
|
||||
annees: { type: Array, required: true },
|
||||
values: { type: Array, required: true },
|
||||
formatValue: { type: Function, required: true },
|
||||
},
|
||||
computed: {
|
||||
maxAbs() {
|
||||
return Math.max(1, ...this.values.filter((v) => v !== null).map((v) => Math.abs(v)));
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
barHeight(v) {
|
||||
if (v === null) return '0%';
|
||||
return Math.max(3, (Math.abs(v) / this.maxAbs) * 100) + '%';
|
||||
},
|
||||
},
|
||||
template:
|
||||
'<div class="figli-mini-trend">' +
|
||||
'<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>' +
|
||||
'</div>' +
|
||||
'<div class="figli-mini-bar-label">{{ annees[i].slice(2) }}</div>' +
|
||||
'</div>' +
|
||||
'</div>',
|
||||
};
|
||||
|
||||
const App = {
|
||||
components: { HBarChart, ColumnChart: VBarChart, MiniTrend },
|
||||
components: { HBarChart, ColumnChart: VBarChart },
|
||||
data() {
|
||||
return { loading: true, error: null, stats: null };
|
||||
},
|
||||
@@ -162,12 +136,6 @@
|
||||
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)
|
||||
@@ -178,12 +146,105 @@
|
||||
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);
|
||||
// Small multiples, one per year -- same source as typeItems() above
|
||||
// (total_par_type_par_annee is the same node-level SQL query, just
|
||||
// also grouped by année, no extra request). Mirrors
|
||||
// dashboard-compte.js's typeItemsParAnnee, but that one filters a
|
||||
// full row list client-side (it has one, scoped to a single
|
||||
// compte); this page never fetches full rows (see this file's
|
||||
// docblock), so the per-année breakdown has to already be
|
||||
// pre-aggregated server-side.
|
||||
typeItemsParAnnee() {
|
||||
if (!this.stats) return [];
|
||||
return this.stats.annees.map((annee) => {
|
||||
const parType = this.stats.total_par_type_par_annee[annee] || {};
|
||||
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);
|
||||
},
|
||||
// Same idea for topClientsItems() -- top_clients_par_annee is
|
||||
// already capped to 8 per year server-side (see
|
||||
// DashboardStatsController::stats()), same reasoning as
|
||||
// dashboard-compte.js capping its own per-année version to 5.
|
||||
topClientsParAnnee() {
|
||||
if (!this.stats) return [];
|
||||
return this.stats.annees.map((annee) => {
|
||||
const items = (this.stats.top_clients_par_annee[annee] || [])
|
||||
.map((c) => ({ label: c.client, value: c.ca }));
|
||||
return { annee, items };
|
||||
}).filter((y) => y.items.length);
|
||||
},
|
||||
// Charges structurelles by client (i.e. by vendor: loyer,
|
||||
// assurance, hébergement, URSSAF...) -- sorted by value descending
|
||||
// server-side already (no alphabetical option asked for here,
|
||||
// unlike the versements-par-compte chart), no top-N cap needed
|
||||
// (see DashboardStatsController::stats()'s own comment -- only a
|
||||
// handful of distinct vendors ever show up on a "charge" line).
|
||||
// `type: 'charge'` on every item is just so colorFor="typeColor"
|
||||
// (same method the type-breakdown chart uses) paints every bar the
|
||||
// same "charge" grey -- every row here is that type by definition.
|
||||
chargeParClientItems() {
|
||||
if (!this.stats) return [];
|
||||
return this.stats.charge_par_client.map((c) => ({ label: c.client, value: c.total, type: 'charge' }));
|
||||
},
|
||||
chargeParClientParAnnee() {
|
||||
if (!this.stats) return [];
|
||||
return this.stats.annees.map((annee) => {
|
||||
const items = (this.stats.charge_par_client_par_annee[annee] || [])
|
||||
.map((c) => ({ label: c.client, value: c.total, type: 'charge' }));
|
||||
return { annee, items };
|
||||
}).filter((y) => y.items.length);
|
||||
},
|
||||
// total_par_type_par_compte is already in the stats response --
|
||||
// it's the same répartition-level query /dashboard/compte uses for
|
||||
// its own (single-compte) type breakdown, just never sliced down
|
||||
// to one type across every compte before now. No new backend
|
||||
// field needed, just pull out the "versement" entry per compte.
|
||||
// Salaire/stage and sous-traitant are the two other ways money
|
||||
// leaves the collective toward a person/entity outside the 6
|
||||
// comptes associés -- added as two extra rows (not split per
|
||||
// compte, there's only one meaningful total each) so the chart
|
||||
// reads as "who/what actually got paid", not just the 6 associés.
|
||||
// Sourced from total_par_type (all-time)/total_par_type_par_annee,
|
||||
// same fields typeItems()/typeItemsParAnnee() above already use.
|
||||
// `type` on every item is what colorFor="typeColor" keys off of
|
||||
// (same typeColor() method typeItems() already feeds) -- the 6
|
||||
// compte rows are coloured as "versement" (they're that same
|
||||
// money, just broken down by recipient instead of summed), and
|
||||
// the two extra rows keep their own type colour.
|
||||
versementsParCompteItems() {
|
||||
if (!this.stats) return [];
|
||||
// Alphabetical among the 6 comptes, but Salaire/stage and
|
||||
// Sous-traitant always pushed on afterward -- they're not
|
||||
// comptes, so they stay out of that ordering and just close out
|
||||
// the list, in that fixed order.
|
||||
const items = Object.entries(this.stats.total_par_type_par_compte)
|
||||
.filter(([, parType]) => parType.versement !== undefined)
|
||||
.map(([compte, parType]) => ({ label: compte, value: parType.versement, type: 'versement' }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, 'fr'));
|
||||
const parType = this.stats.total_par_type || {};
|
||||
if (parType.salaire_stage) items.push({ label: TYPE_LABELS.salaire_stage, value: parType.salaire_stage, type: 'salaire_stage' });
|
||||
if (parType.sous_traitant) items.push({ label: TYPE_LABELS.sous_traitant, value: parType.sous_traitant, type: 'sous_traitant' });
|
||||
return items;
|
||||
},
|
||||
// Small multiples, one per year -- same source as typeItemsParAnnee
|
||||
// above (total_par_type_par_compte_par_annee, already there for
|
||||
// /dashboard/compte's own per-année breakdown).
|
||||
versementsParCompteParAnnee() {
|
||||
if (!this.stats) return [];
|
||||
return this.stats.annees.map((annee) => {
|
||||
const parCompte = this.stats.total_par_type_par_compte_par_annee[annee] || {};
|
||||
const items = Object.entries(parCompte)
|
||||
.filter(([, parType]) => parType.versement !== undefined)
|
||||
.map(([compte, parType]) => ({ label: compte, value: parType.versement, type: 'versement' }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, 'fr'));
|
||||
const parType = this.stats.total_par_type_par_annee[annee] || {};
|
||||
if (parType.salaire_stage) items.push({ label: TYPE_LABELS.salaire_stage, value: parType.salaire_stage, type: 'salaire_stage' });
|
||||
if (parType.sous_traitant) items.push({ label: TYPE_LABELS.sous_traitant, value: parType.sous_traitant, type: 'sous_traitant' });
|
||||
return { annee, items };
|
||||
}).filter((y) => y.items.length);
|
||||
},
|
||||
totalCA() {
|
||||
if (!this.stats) return 0;
|
||||
@@ -206,15 +267,17 @@
|
||||
formatEurRound(v) {
|
||||
return EUR_ROUND.format(v);
|
||||
},
|
||||
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';
|
||||
},
|
||||
// Sum of an hbar chart's own items -- for the "Total" line under
|
||||
// Charges structurelles par client (main chart and each per-année
|
||||
// card): those items are already whatever's actually plotted, so
|
||||
// summing them directly (rather than a separate stats field) keeps
|
||||
// the total consistent with the bars above it by construction.
|
||||
sumItems(items) {
|
||||
return items.reduce((sum, i) => sum + i.value, 0);
|
||||
},
|
||||
async load() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
@@ -305,10 +305,44 @@
|
||||
return json;
|
||||
}
|
||||
|
||||
async function fetchYearTotals(annee) {
|
||||
const res = await fetch('/lignes/api/totaux?annee=' + encodeURIComponent(annee), { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('/lignes/api/totaux a répondu ' + res.status);
|
||||
return res.json();
|
||||
// Sums the same fields LedgerRowsController::index() rows carry into
|
||||
// the {montant_ht, cotisation, montant_ttc, ecart, par_compte} shape
|
||||
// the footer template expects.
|
||||
function aggregateTotals(rows) {
|
||||
let montantHt = 0;
|
||||
let cotisation = 0;
|
||||
let montantTtc = 0;
|
||||
let ecart = 0;
|
||||
const parCompte = {};
|
||||
for (const r of rows) {
|
||||
montantHt += r.montant_ht || 0;
|
||||
cotisation += r.cotisation || 0;
|
||||
montantTtc += r.montant_ttc || 0;
|
||||
ecart += r.ecart || 0;
|
||||
for (const [c, v] of Object.entries(r.parCompte)) {
|
||||
parCompte[c] = (parCompte[c] || 0) + v;
|
||||
}
|
||||
}
|
||||
const round = (v) => Math.round(v * 100) / 100;
|
||||
return {
|
||||
montant_ht: round(montantHt),
|
||||
cotisation: round(cotisation),
|
||||
montant_ttc: round(montantTtc),
|
||||
ecart: round(ecart),
|
||||
par_compte: Object.fromEntries(Object.entries(parCompte).map(([c, v]) => [c, round(v)])),
|
||||
};
|
||||
}
|
||||
|
||||
// Per-année footer totals, filtered the same way the table itself is --
|
||||
// fetches every row for the year via the same server-side-filtered
|
||||
// endpoint the table window uses (fetchFilteredLignes), then sums them
|
||||
// client-side. Replaces the old /lignes/api/totaux (LedgerStatsController::
|
||||
// totauxAnnee(), always unfiltered) now that the footer needs to
|
||||
// reflect whatever's actually on screen, not the whole year regardless
|
||||
// of the active filters.
|
||||
async function fetchYearTotals(annee, filters) {
|
||||
const rows = await fetchFilteredLignes({ annee }, filters);
|
||||
return Object.assign({ annee }, aggregateTotals(rows));
|
||||
}
|
||||
|
||||
// URL hash (#compte=Maud,Bachir&type=versement,achat&annee=2023&ecarts=1&aller=2024)
|
||||
@@ -1455,14 +1489,33 @@
|
||||
// Every toolbar filter change routes through here: with filtering
|
||||
// now server-side (see fetchFilteredLignes()), there's no more
|
||||
// "just recompute a client-side view" -- the currently loaded
|
||||
// window has to be re-fetched with the new filter applied. Queued
|
||||
// through the same chain as loadOlder()/loadNewer() (see
|
||||
// _queueWindowOp) since several filters can change in the same
|
||||
// tick (e.g. mounted() restoring them all from the URL hash at
|
||||
// once), and interleaving their fetches would race on `rows` the
|
||||
// same way parallel loadOlder()/loadNewer() calls used to.
|
||||
// window has to be re-fetched with the new filter applied. The
|
||||
// reload itself is queued through the same chain as loadOlder()/
|
||||
// loadNewer() (see _queueWindowOp) since several filters can change
|
||||
// in the same tick (e.g. mounted() restoring them all from the URL
|
||||
// hash at once), and interleaving their fetches would race on
|
||||
// `rows` the same way parallel loadOlder()/loadNewer() calls used
|
||||
// to. ensureScrollable() is deliberately chained AFTER that queued
|
||||
// op settles, not passed into it: ensureScrollable() itself calls
|
||||
// loadOlder()/loadNewer(), which each enqueue their own op onto the
|
||||
// very same chain -- queuing it *inside* the op currently occupying
|
||||
// that chain made the chain await its own continuation (the queued
|
||||
// op can't finish until its child call, appended behind it on the
|
||||
// same chain, finishes first) and deadlocked solid the moment a
|
||||
// filter actually left too few rows to fill the viewport, wedging
|
||||
// every future filter change and scroll-triggered load right along
|
||||
// with it.
|
||||
//
|
||||
// loadCurrentYearTotals() is called unconditionally here (not just
|
||||
// left to detectCurrentYear()'s own trigger): that one only
|
||||
// re-fetches when the *visible year* changes, so a filter change
|
||||
// while staying on the same year would otherwise leave the footer
|
||||
// showing stale, pre-filter totals. Independent of the window
|
||||
// reload above (different endpoint call, no shared state), so no
|
||||
// need to chain it through the same queue.
|
||||
onFilterChanged() {
|
||||
this._queueWindowOp(() => this.reloadWindow().then(() => this.ensureScrollable()));
|
||||
this._queueWindowOp(() => this.reloadWindow()).then(() => this.ensureScrollable());
|
||||
this.loadCurrentYearTotals();
|
||||
this.syncHash();
|
||||
},
|
||||
// Keeps extending the window (both directions) as long as a filter
|
||||
@@ -1581,7 +1634,7 @@
|
||||
if (!this.currentYear) return;
|
||||
this.currentYearLoading = true;
|
||||
try {
|
||||
this.currentYearTotals = await fetchYearTotals(this.currentYear);
|
||||
this.currentYearTotals = await fetchYearTotals(this.currentYear, this.currentFilters());
|
||||
} catch (err) {
|
||||
this.currentYearTotals = null;
|
||||
} finally {
|
||||
|
||||
@@ -40,7 +40,23 @@ class DashboardController extends ControllerBase {
|
||||
}
|
||||
|
||||
/**
|
||||
* Third page: one compte associé (freelance) at a time -- entrées client
|
||||
* Third page: solde par compte, all-time and year by year -- split out
|
||||
* of the general /dashboard so that page stays focused on activité/CA/
|
||||
* type/client rather than per-compte balances. Same
|
||||
* /dashboard/api/stats data as /dashboard, just a different slice of it.
|
||||
*/
|
||||
public function repartitionView() {
|
||||
return [
|
||||
'#theme' => 'figli_compta_dashboard_repartition',
|
||||
'#current_route' => 'figli_compta_ledger.dashboard_repartition',
|
||||
'#attached' => [
|
||||
'library' => ['figli_compta_ledger/dashboard_repartition'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fourth 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.
|
||||
|
||||
@@ -60,9 +60,9 @@ class DashboardStatsController extends ControllerBase {
|
||||
// share, pre-summed per (annee, compte, type) 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()), and the per-compte type
|
||||
// breakdown used by /dashboard/compte.
|
||||
// year's closing balance -- same logic as home.js's
|
||||
// aggregateTotals()/fetchYearTotals() use for the /lignes footer),
|
||||
// and the per-compte type breakdown used by /dashboard/compte.
|
||||
$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');
|
||||
@@ -82,7 +82,11 @@ class DashboardStatsController extends ControllerBase {
|
||||
// --- Aggregate the node-level rows in PHP. ---
|
||||
$caParAnnee = [];
|
||||
$totalParType = [];
|
||||
$totalParTypeParAnnee = [];
|
||||
$caParClient = [];
|
||||
$caParClientParAnnee = [];
|
||||
$chargeParClient = [];
|
||||
$chargeParClientParAnnee = [];
|
||||
$annees = [];
|
||||
foreach ($nodeRows as $row) {
|
||||
$montant = $row->montant_ht !== NULL ? (float) $row->montant_ht : 0.0;
|
||||
@@ -97,6 +101,16 @@ class DashboardStatsController extends ControllerBase {
|
||||
$client = $row->client ?: '(sans client)';
|
||||
$caParClient[$client] = ($caParClient[$client] ?? 0) + $montant;
|
||||
}
|
||||
// Same "who does this money go to" breakdown as $caParClient above,
|
||||
// but for charges structurelles instead of entrées -- field_client
|
||||
// on a charge line is the vendor/organisme (loyer, assurance,
|
||||
// URSSAF...), not a paying client, but it's the same field/same
|
||||
// taxonomy, so the same grouping applies. Absolute value, same
|
||||
// convention as $totalParType.
|
||||
if ($row->type === 'charge') {
|
||||
$client = $row->client ?: '(sans client)';
|
||||
$chargeParClient[$client] = ($chargeParClient[$client] ?? 0) + abs($montant);
|
||||
}
|
||||
|
||||
if (!$this->isAnneeValide($row->annee)) {
|
||||
continue;
|
||||
@@ -104,6 +118,22 @@ class DashboardStatsController extends ControllerBase {
|
||||
$annees[$row->annee] = TRUE;
|
||||
if ($row->type === 'entree') {
|
||||
$caParAnnee[$row->annee] = ($caParAnnee[$row->annee] ?? 0) + $montant;
|
||||
// Per-année équivalent of $caParClient above -- backs the "Top
|
||||
// clients par année" small multiples, same rows, no extra query.
|
||||
$client = $row->client ?: '(sans client)';
|
||||
$caParClientParAnnee[$row->annee][$client] =
|
||||
($caParClientParAnnee[$row->annee][$client] ?? 0) + $montant;
|
||||
}
|
||||
if ($row->type === 'charge') {
|
||||
$client = $row->client ?: '(sans client)';
|
||||
$chargeParClientParAnnee[$row->annee][$client] =
|
||||
($chargeParClientParAnnee[$row->annee][$client] ?? 0) + abs($montant);
|
||||
}
|
||||
// Per-année équivalent of $totalParType above -- backs the
|
||||
// "Répartition de l'activité par type" small multiples.
|
||||
if ($row->type !== 'ouverture') {
|
||||
$totalParTypeParAnnee[$row->annee][$row->type] =
|
||||
($totalParTypeParAnnee[$row->annee][$row->type] ?? 0) + abs($montant);
|
||||
}
|
||||
}
|
||||
arsort($caParClient);
|
||||
@@ -116,6 +146,43 @@ class DashboardStatsController extends ControllerBase {
|
||||
$topClients[] = ['client' => $client, 'ca' => round($total, 2)];
|
||||
}
|
||||
|
||||
// Top 8 (not 12 like the all-time chart above) -- one per year keeps
|
||||
// the small-multiples grid readable, same reasoning as the per-compte
|
||||
// équivalent on /dashboard/compte (there it's top 5, computed
|
||||
// client-side from full row data; here it's top 8, computed here
|
||||
// since dashboard.js only ever gets pre-aggregated SQL, never full
|
||||
// rows -- see this controller's docblock).
|
||||
$topClientsParAnnee = [];
|
||||
foreach ($caParClientParAnnee as $annee => $parClient) {
|
||||
arsort($parClient);
|
||||
$topClientsParAnnee[$annee] = [];
|
||||
$i = 0;
|
||||
foreach ($parClient as $client => $total) {
|
||||
if ($i++ >= 8) {
|
||||
break;
|
||||
}
|
||||
$topClientsParAnnee[$annee][] = ['client' => $client, 'ca' => round($total, 2)];
|
||||
}
|
||||
}
|
||||
|
||||
// No top-N cap here (unlike top clients above) -- structural charges
|
||||
// only ever go to a handful of recurring vendors (loyer, assurance,
|
||||
// hébergement, URSSAF...), not the 100+ distinct clients entrées can
|
||||
// have, so the full list is already short.
|
||||
arsort($chargeParClient);
|
||||
$chargeParClientList = [];
|
||||
foreach ($chargeParClient as $client => $total) {
|
||||
$chargeParClientList[] = ['client' => $client, 'total' => round($total, 2)];
|
||||
}
|
||||
$chargeParClientParAnneeList = [];
|
||||
foreach ($chargeParClientParAnnee as $annee => $parClient) {
|
||||
arsort($parClient);
|
||||
$chargeParClientParAnneeList[$annee] = [];
|
||||
foreach ($parClient as $client => $total) {
|
||||
$chargeParClientParAnneeList[$annee][] = ['client' => $client, 'total' => round($total, 2)];
|
||||
}
|
||||
}
|
||||
|
||||
// --- Aggregate the répartition-level rows in PHP. ---
|
||||
$soldeParCompte = [];
|
||||
$soldeParCompteParAnnee = [];
|
||||
@@ -170,12 +237,22 @@ class DashboardStatsController extends ControllerBase {
|
||||
// year, which can differ year to year.
|
||||
ksort($soldeParCompteParAnnee);
|
||||
ksort($totalParTypeParCompteParAnnee);
|
||||
ksort($totalParTypeParAnnee);
|
||||
ksort($topClientsParAnnee);
|
||||
ksort($chargeParClientParAnneeList);
|
||||
|
||||
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),
|
||||
'total_par_type_par_annee' => array_map(
|
||||
fn ($parType) => array_map(fn ($v) => round($v, 2), $parType),
|
||||
$totalParTypeParAnnee
|
||||
),
|
||||
'top_clients' => $topClients,
|
||||
'top_clients_par_annee' => $topClientsParAnnee,
|
||||
'charge_par_client' => $chargeParClientList,
|
||||
'charge_par_client_par_annee' => $chargeParClientParAnneeList,
|
||||
'solde_par_compte' => array_map(fn ($v) => round($v, 2), $soldeParCompte),
|
||||
'solde_par_compte_par_annee' => $soldeParCompteParAnnee,
|
||||
'total_par_type_par_compte' => array_map(
|
||||
|
||||
@@ -62,10 +62,10 @@ class HistoryController extends ControllerBase {
|
||||
// pinned next to the page title) -- this controller has no twig
|
||||
// template of its own to put a real <nav> in, but the CSS only
|
||||
// ever targets the class, not the tag, so a render-array
|
||||
// 'container' (<div>) here looks identical. None of the three
|
||||
// 'container' (<div>) here looks identical. None of the four
|
||||
// links is ever "active" here since this history feed isn't one
|
||||
// of them -- same as visiting it from any of the other pages'
|
||||
// nav, which doesn't include a 4th "Historique" entry either.
|
||||
// nav, which doesn't include a 5th "Historique" entry either.
|
||||
'nav' => [
|
||||
'#type' => 'container',
|
||||
'#attributes' => ['class' => ['figli-page-nav']],
|
||||
@@ -76,9 +76,14 @@ class HistoryController extends ControllerBase {
|
||||
],
|
||||
'dashboard' => [
|
||||
'#type' => 'link',
|
||||
'#title' => $this->t('Dashboard'),
|
||||
'#title' => $this->t('SAS'),
|
||||
'#url' => Url::fromRoute('figli_compta_ledger.dashboard'),
|
||||
],
|
||||
'dashboard_repartition' => [
|
||||
'#type' => 'link',
|
||||
'#title' => $this->t('Répartition/Soldes'),
|
||||
'#url' => Url::fromRoute('figli_compta_ledger.dashboard_repartition'),
|
||||
],
|
||||
'dashboard_compte' => [
|
||||
'#type' => 'link',
|
||||
'#title' => $this->t('Par compte'),
|
||||
|
||||
@@ -5,87 +5,20 @@ namespace Drupal\figli_compta_ledger\Controller;
|
||||
use Drupal\Core\Controller\ControllerBase;
|
||||
use Drupal\node\NodeInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Small aggregate endpoints backing the /lignes sliding window: the
|
||||
* row-level JSON:API fetch only ever covers a date range (see home.js), so
|
||||
* neither the totals footer nor the "Année" dropdown can be computed from
|
||||
* whatever's currently loaded -- they need their own always-accurate
|
||||
* queries, decoupled from the row window.
|
||||
* row-level fetch only ever covers a date range (see home.js), so the
|
||||
* "Année" dropdown can't be computed from whatever's currently loaded --
|
||||
* it needs its own always-accurate query, decoupled from the row window.
|
||||
* The footer totals used to live here too (totauxAnnee(), unfiltered)
|
||||
* until the footer needed to reflect the active toolbar filters -- it's
|
||||
* now computed client-side in home.js from LedgerRowsController::index()
|
||||
* rows instead, the same server-side-filtered endpoint the table itself
|
||||
* uses.
|
||||
*/
|
||||
class LedgerStatsController extends ControllerBase {
|
||||
|
||||
/**
|
||||
* GET /lignes/api/totaux?annee=2023 -- per-compte répartition sums (plus
|
||||
* montant HT/TTC/écart totals) for every ligne_comptable dated that
|
||||
* year, including ouverture lines: the footer is meant to read as the
|
||||
* actual account balance (solde) for the year, i.e. the same "clôture
|
||||
* calculée" (ouverture + every movement dated within the year) that
|
||||
* reconciliationOuverture() compares the *next* year's ouverture
|
||||
* against. Excluding ouverture here would make this a net-movement
|
||||
* figure instead, which never matches what the reconciliation badge's
|
||||
* tooltip cites for the same year.
|
||||
*/
|
||||
public function totauxAnnee(Request $request) {
|
||||
$annee = $request->query->get('annee');
|
||||
if (!$annee || !preg_match('/^\d{4}$/', $annee)) {
|
||||
return new JsonResponse(['error' => 'Paramètre "annee" invalide.'], 400);
|
||||
}
|
||||
|
||||
$storage = $this->entityTypeManager()->getStorage('node');
|
||||
$nids = $storage->getQuery()
|
||||
->accessCheck(TRUE)
|
||||
->condition('type', 'ligne_comptable')
|
||||
->condition('field_date_ligne', $annee . '-01-01', '>=')
|
||||
->condition('field_date_ligne', ((int) $annee + 1) . '-01-01', '<')
|
||||
->execute();
|
||||
|
||||
$par_compte = [];
|
||||
$montant_ht = 0.0;
|
||||
$cotisation = 0.0;
|
||||
$montant_ttc = 0.0;
|
||||
$ecart = 0.0;
|
||||
foreach ($storage->loadMultiple($nids) as $node) {
|
||||
$ht = $node->hasField('field_montant_ht') && !$node->get('field_montant_ht')->isEmpty()
|
||||
? (float) $node->get('field_montant_ht')->value : 0.0;
|
||||
$ttc = $node->hasField('field_montant_ttc') && !$node->get('field_montant_ttc')->isEmpty()
|
||||
? (float) $node->get('field_montant_ttc')->value : 0.0;
|
||||
$montant_ht += $ht;
|
||||
$montant_ttc += $ttc;
|
||||
// Blank (never 0) for most lines -- only "Entrée client" lines
|
||||
// with the cotisation checkbox on ever have this field set (see
|
||||
// figli_compta_ledger_node_presave()) -- but summing a blank
|
||||
// value as 0 here is exactly right for a total.
|
||||
if ($node->hasField('field_cotisation_urssaf') && !$node->get('field_cotisation_urssaf')->isEmpty()) {
|
||||
$cotisation += (float) $node->get('field_cotisation_urssaf')->value;
|
||||
}
|
||||
|
||||
$somme = 0.0;
|
||||
foreach ($node->get('field_repartition')->referencedEntities() as $paragraph) {
|
||||
if (!$paragraph->hasField('field_montant') || $paragraph->get('field_montant')->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
$montant = (float) $paragraph->get('field_montant')->value;
|
||||
$compte = $paragraph->get('field_compte')->entity ? $paragraph->get('field_compte')->entity->label() : NULL;
|
||||
if ($compte) {
|
||||
$par_compte[$compte] = ($par_compte[$compte] ?? 0) + $montant;
|
||||
}
|
||||
$somme += $montant;
|
||||
}
|
||||
$ecart += round($ht - $somme, 2);
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'annee' => $annee,
|
||||
'montant_ht' => round($montant_ht, 2),
|
||||
'cotisation' => round($cotisation, 2),
|
||||
'montant_ttc' => round($montant_ttc, 2),
|
||||
'ecart' => round($ecart, 2),
|
||||
'par_compte' => array_map(fn ($v) => round($v, 2), $par_compte),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /lignes/api/annees -- distinct years, most recent first, with at
|
||||
* least 5 lines. The threshold exists specifically to keep the handful
|
||||
|
||||
+2
-1
@@ -10,7 +10,8 @@
|
||||
#}
|
||||
<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') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">SAS</a>
|
||||
<a href="{{ path('figli_compta_ledger.dashboard_repartition') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_repartition' ? 'is-active' : '' }}">Répartition/Soldes</a>
|
||||
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a>
|
||||
</nav>
|
||||
{% verbatim %}
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
{#
|
||||
Répartition / Soldes: solde par compte (all-time + évolution année par
|
||||
année), split out of the general dashboard (figli-compta-dashboard.html.twig)
|
||||
to keep that one focused on activité/CA/type/client.
|
||||
|
||||
{% verbatim %} below: this is Vue template syntax, not Twig -- both use
|
||||
{{ }}, so verbatim tells Twig to leave it alone and let Vue compile it
|
||||
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' : '' }}">SAS</a>
|
||||
<a href="{{ path('figli_compta_ledger.dashboard_repartition') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_repartition' ? 'is-active' : '' }}">Répartition/Soldes</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>
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
{% endverbatim %}
|
||||
@@ -1,6 +1,7 @@
|
||||
{#
|
||||
Charts and aggregate totals: solde par compte, chiffre d'affaires par
|
||||
année, répartition par type, top clients. The spreadsheet-like
|
||||
Charts and aggregate totals: chiffre d'affaires par année, répartition
|
||||
par type, top clients. Solde par compte lives on its own page now (see
|
||||
figli-compta-dashboard-repartition.html.twig). The spreadsheet-like
|
||||
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
|
||||
@@ -9,7 +10,8 @@
|
||||
#}
|
||||
<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') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">SAS</a>
|
||||
<a href="{{ path('figli_compta_ledger.dashboard_repartition') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_repartition' ? 'is-active' : '' }}">Répartition/Soldes</a>
|
||||
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a>
|
||||
</nav>
|
||||
{% verbatim %}
|
||||
@@ -26,10 +28,6 @@
|
||||
<div class="figli-summary-label">CA {{ caAnneeEnCours.annee }}</div>
|
||||
<div class="figli-summary-value">{{ formatEurRound(caAnneeEnCours.value) }}</div>
|
||||
</div>
|
||||
<div class="figli-summary-card" v-for="item in soldeParCompteItems.slice(0, 1)" :key="'top-' + item.label">
|
||||
<div class="figli-summary-label">Meilleur solde</div>
|
||||
<div class="figli-summary-value" :class="item.value < 0 ? 'is-negative' : 'is-positive'">{{ item.label }} · {{ formatEurRound(item.value) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="figli-chart-section">
|
||||
@@ -38,33 +36,54 @@
|
||||
<column-chart :items="caParAnneeItems" :format-value="formatEurRound"></column-chart>
|
||||
</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>
|
||||
<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 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>
|
||||
<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 class="figli-chart-section">
|
||||
<h2>Charges structurelles par client</h2>
|
||||
<p class="figli-note">Montant total (HT, valeur absolue) des charges structurelles par vendeur/organisme, toutes années confondues.</p>
|
||||
<h-bar-chart :items="chargeParClientItems" :format-value="formatEurRound" :color-for="typeColor"></h-bar-chart>
|
||||
<div class="figli-chart-total">Total : {{ formatEurRound(sumItems(chargeParClientItems)) }}</div>
|
||||
<div class="figli-year-hbar-grid">
|
||||
<div class="figli-year-hbar-card" v-for="y in chargeParClientParAnnee" :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 class="figli-chart-total is-compact">Total : {{ formatEurRound(sumItems(y.items)) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="figli-chart-section">
|
||||
<h2>Total des versements par compte</h2>
|
||||
<p class="figli-note">Montant total versé à chaque compte associé, plus les salaires/stages et sous-traitants, toutes années confondues.</p>
|
||||
<h-bar-chart :items="versementsParCompteItems" :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 versementsParCompteParAnnee" :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>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
#}
|
||||
<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') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">SAS</a>
|
||||
<a href="{{ path('figli_compta_ledger.dashboard_repartition') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_repartition' ? 'is-active' : '' }}">Répartition/Soldes</a>
|
||||
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a>
|
||||
</nav>
|
||||
{% verbatim %}
|
||||
|
||||
Reference in New Issue
Block a user