Compare commits
15
Commits
f7e7ae3265
...
prod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e1c421e5b | ||
|
|
5eca3804bf | ||
|
|
d2f3179f01 | ||
|
|
3688bddba8 | ||
|
|
af36591d70 | ||
|
|
74d8eb2aa2 | ||
|
|
96bd9502d2 | ||
|
|
cb2a0fcdaa | ||
|
|
612269ec6d | ||
|
|
d17a3e2e33 | ||
|
|
692f7f3ea3 | ||
|
|
8dfb4af98a | ||
|
|
43817dcdce | ||
|
|
3552b5a79d | ||
|
|
c927771795 |
@@ -6,4 +6,5 @@
|
|||||||
/web/sites/*/files/
|
/web/sites/*/files/
|
||||||
/web/sites/*/settings.local.php
|
/web/sites/*/settings.local.php
|
||||||
/web/sites/*/settings.php
|
/web/sites/*/settings.php
|
||||||
|
/private/
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ dependencies:
|
|||||||
config:
|
config:
|
||||||
- node.type.ligne_comptable
|
- node.type.ligne_comptable
|
||||||
module:
|
module:
|
||||||
|
- figli_compta_ledger
|
||||||
- node
|
- node
|
||||||
- system
|
- system
|
||||||
id: admin
|
id: admin
|
||||||
@@ -13,7 +14,9 @@ weight: 6
|
|||||||
is_admin: false
|
is_admin: false
|
||||||
permissions:
|
permissions:
|
||||||
- 'access content'
|
- 'access content'
|
||||||
|
- 'access figli ledger'
|
||||||
- 'create ligne_comptable content'
|
- 'create ligne_comptable content'
|
||||||
- 'delete any ligne_comptable content'
|
- 'delete any ligne_comptable content'
|
||||||
- 'edit any ligne_comptable content'
|
- 'edit any ligne_comptable content'
|
||||||
|
- 'import ligne_comptable releve'
|
||||||
- 'view ligne_comptable revisions'
|
- 'view ligne_comptable revisions'
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ dependencies:
|
|||||||
module:
|
module:
|
||||||
- file
|
- file
|
||||||
- filter
|
- filter
|
||||||
- system
|
|
||||||
_core:
|
_core:
|
||||||
default_config_hash: wkW7P5A53YhGmsgamrmTbfwpZrqdnPYiJdoAZQtdmJg
|
default_config_hash: wkW7P5A53YhGmsgamrmTbfwpZrqdnPYiJdoAZQtdmJg
|
||||||
id: authenticated
|
id: authenticated
|
||||||
@@ -15,6 +14,5 @@ label: 'Authenticated user'
|
|||||||
weight: 1
|
weight: 1
|
||||||
is_admin: false
|
is_admin: false
|
||||||
permissions:
|
permissions:
|
||||||
- 'access content'
|
|
||||||
- 'delete own files'
|
- 'delete own files'
|
||||||
- 'use text format basic_html'
|
- 'use text format basic_html'
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ dependencies:
|
|||||||
config:
|
config:
|
||||||
- node.type.ligne_comptable
|
- node.type.ligne_comptable
|
||||||
module:
|
module:
|
||||||
|
- figli_compta_ledger
|
||||||
- node
|
- node
|
||||||
- system
|
- system
|
||||||
id: editeur
|
id: editeur
|
||||||
@@ -13,7 +14,9 @@ weight: 5
|
|||||||
is_admin: false
|
is_admin: false
|
||||||
permissions:
|
permissions:
|
||||||
- 'access content'
|
- 'access content'
|
||||||
|
- 'access figli ledger'
|
||||||
- 'create ligne_comptable content'
|
- 'create ligne_comptable content'
|
||||||
- 'delete any ligne_comptable content'
|
- 'delete any ligne_comptable content'
|
||||||
- 'edit any ligne_comptable content'
|
- 'edit any ligne_comptable content'
|
||||||
|
- 'import ligne_comptable releve'
|
||||||
- 'view ligne_comptable revisions'
|
- 'view ligne_comptable revisions'
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ dependencies:
|
|||||||
config:
|
config:
|
||||||
- node.type.ligne_comptable
|
- node.type.ligne_comptable
|
||||||
module:
|
module:
|
||||||
|
- figli_compta_ledger
|
||||||
- node
|
- node
|
||||||
- system
|
- system
|
||||||
id: user
|
id: user
|
||||||
@@ -13,4 +14,5 @@ weight: 4
|
|||||||
is_admin: false
|
is_admin: false
|
||||||
permissions:
|
permissions:
|
||||||
- 'access content'
|
- 'access content'
|
||||||
|
- 'access figli ledger'
|
||||||
- 'view ligne_comptable revisions'
|
- 'view ligne_comptable revisions'
|
||||||
|
|||||||
@@ -69,7 +69,14 @@ html.gin--dark-mode .figli-page-nav a.is-active {
|
|||||||
([data-drupal-messages-fallback], used when Drupal.Message.add() -- our
|
([data-drupal-messages-fallback], used when Drupal.Message.add() -- our
|
||||||
own MessageCommand-driven AJAX messages included -- has no region to
|
own MessageCommand-driven AJAX messages included -- has no region to
|
||||||
attach to). Auto-dismiss timing for non-error messages is handled in
|
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],
|
||||||
[data-drupal-messages-fallback] {
|
[data-drupal-messages-fallback] {
|
||||||
position: fixed !important;
|
position: fixed !important;
|
||||||
@@ -78,7 +85,7 @@ html.gin--dark-mode .figli-page-nav a.is-active {
|
|||||||
left: auto !important;
|
left: auto !important;
|
||||||
width: auto;
|
width: auto;
|
||||||
max-width: 22rem;
|
max-width: 22rem;
|
||||||
z-index: 1000;
|
z-index: 100000;
|
||||||
}
|
}
|
||||||
[data-drupal-messages] .messages-list__wrapper,
|
[data-drupal-messages] .messages-list__wrapper,
|
||||||
[data-drupal-messages] .messages__wrapper,
|
[data-drupal-messages] .messages__wrapper,
|
||||||
|
|||||||
@@ -364,6 +364,25 @@ html.gin--dark-mode #figli-dashboard-app {
|
|||||||
font-size: 0.7rem;
|
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) --- */
|
/* --- Year small multiples (par année, next to the all-time chart) --- */
|
||||||
#figli-dashboard-app .figli-year-hbar-grid {
|
#figli-dashboard-app .figli-year-hbar-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -276,6 +276,22 @@ html.gin--dark-mode #figli-home-app {
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* "+ Ajouter une ligne" + "Importer un relevé" live in the sticky footer
|
||||||
|
now (they used to lead the toolbar and crowd its filter row). Compact
|
||||||
|
overrides for Gin's .button, which is sized for full admin forms --
|
||||||
|
way too big inside a dense totals row. Size-only overrides (no
|
||||||
|
colors): Gin's own light/dark button palettes keep applying. */
|
||||||
|
#figli-home-app tr.figli-totals-row .button {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 0.4rem 0 0;
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
#figli-home-app td.figli-solde-crediteur {
|
#figli-home-app td.figli-solde-crediteur {
|
||||||
color: var(--figli-positive);
|
color: var(--figli-positive);
|
||||||
}
|
}
|
||||||
@@ -334,7 +350,15 @@ html.gin--dark-mode #figli-home-app {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: color-mix(in srgb, var(--figli-warning) 15%, transparent);
|
background: color-mix(in srgb, var(--figli-warning) 15%, transparent);
|
||||||
color: var(--figli-warning);
|
color: var(--figli-warning);
|
||||||
white-space: nowrap;
|
/* The Signalement column is narrow (6%) -- a badge must wrap inside
|
||||||
|
the cell instead of overflowing into the neighboring column. The
|
||||||
|
cell itself already allows wrapping (.figli-flag-cell); this makes
|
||||||
|
the badge wrap too, including single long tokens (anywhere) and
|
||||||
|
within its own padding box (max-width + border-box). */
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Column highlight to pair with the row hover, forming a crosshair over
|
/* Column highlight to pair with the row hover, forming a crosshair over
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
--flform-border-soft: #e8eaed;
|
--flform-border-soft: #e8eaed;
|
||||||
--flform-label: #4b5563;
|
--flform-label: #4b5563;
|
||||||
--flform-text: #1a1a1a;
|
--flform-text: #1a1a1a;
|
||||||
|
--flform-bg: #ffffff;
|
||||||
--flform-bg-subtle: #f7f8fa;
|
--flform-bg-subtle: #f7f8fa;
|
||||||
--flform-accent: #2f6f4f;
|
--flform-accent: #2f6f4f;
|
||||||
--flform-danger: #b3261e;
|
--flform-danger: #b3261e;
|
||||||
@@ -43,6 +44,7 @@ html.gin--dark-mode .figli-ledger-form {
|
|||||||
--flform-border-soft: #333438;
|
--flform-border-soft: #333438;
|
||||||
--flform-label: #a1a5ab;
|
--flform-label: #a1a5ab;
|
||||||
--flform-text: #e8e9ea;
|
--flform-text: #e8e9ea;
|
||||||
|
--flform-bg: #3a3b40;
|
||||||
--flform-bg-subtle: #2a2b2e;
|
--flform-bg-subtle: #2a2b2e;
|
||||||
--flform-accent: #5fb98a;
|
--flform-accent: #5fb98a;
|
||||||
--flform-danger: #ff6b6b;
|
--flform-danger: #ff6b6b;
|
||||||
@@ -108,6 +110,26 @@ html.gin--dark-mode .figli-ledger-form {
|
|||||||
grid-column: 4 / 5;
|
grid-column: 4 / 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Montant relevé bancaire (field_montant_releve): the import's
|
||||||
|
immutable bank reference, displayed as plain text -- the widget is
|
||||||
|
replaced by a #type => 'item' in figli_compta_ledger_form_alter(),
|
||||||
|
no input box at all. Under Montant TTC in the same column so the two
|
||||||
|
amounts compare at a glance while sorting an imported line; the
|
||||||
|
"réf." label marker + tabular digits carry the "value you look at,
|
||||||
|
not one you type" convention. Hidden entirely on lines with no bank
|
||||||
|
amount (manually entered ones). */
|
||||||
|
.figli-ledger-form > .field--name-field-montant-releve {
|
||||||
|
grid-column: 4 / 5;
|
||||||
|
}
|
||||||
|
.figli-ledger-form > .field--name-field-montant-releve label::after {
|
||||||
|
content: " · réf.";
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--flform-label);
|
||||||
|
}
|
||||||
|
.figli-ledger-form > .field--name-field-montant-releve .figli-releve-value {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
/* Field basics */
|
/* Field basics */
|
||||||
.figli-ledger-form .form-item__label {
|
.figli-ledger-form .form-item__label {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
@@ -192,10 +214,10 @@ html.gin--dark-mode .figli-ledger-form {
|
|||||||
}
|
}
|
||||||
.figli-ledger-form table.field-multiple-table thead th {
|
.figli-ledger-form table.field-multiple-table thead th {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0.4rem 0.6rem 0.25rem;
|
padding: 0.3rem 0.5rem 0.2rem;
|
||||||
}
|
}
|
||||||
.figli-ledger-form table.field-multiple-table tbody td {
|
.figli-ledger-form table.field-multiple-table tbody td {
|
||||||
padding: 0.35rem 0.6rem;
|
padding: 0.2rem 0.5rem;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
border-top: 1px solid var(--flform-border-soft);
|
border-top: 1px solid var(--flform-border-soft);
|
||||||
}
|
}
|
||||||
@@ -204,12 +226,25 @@ html.gin--dark-mode .figli-ledger-form {
|
|||||||
}
|
}
|
||||||
.figli-ledger-form .paragraphs-subform {
|
.figli-ledger-form .paragraphs-subform {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.6rem;
|
gap: 0.5rem;
|
||||||
|
/* Réserve le coin haut-droit (bouton Remove du paragraph-top, en
|
||||||
|
position:absolute) : les inputs ne passent plus dessous et
|
||||||
|
rétrécissent d'autant. */
|
||||||
|
padding-right: 4.4rem;
|
||||||
}
|
}
|
||||||
.figli-ledger-form .paragraphs-subform > .js-form-wrapper {
|
.figli-ledger-form .paragraphs-subform > .js-form-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
/* Le Compte (nom, souvent long) prend une part plus large que le
|
||||||
|
Montant (chiffre court) -- tous deux plus étroits qu'avant, la zone
|
||||||
|
du Remove étant réservée ci-dessus. */
|
||||||
|
.figli-ledger-form .paragraphs-subform > .js-form-wrapper.field--name-field-compte {
|
||||||
|
flex: 1.6;
|
||||||
|
}
|
||||||
|
.figli-ledger-form .paragraphs-subform > .js-form-wrapper.field--name-field-montant {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Buttons: "Ajouter Répartition" / "Add another item", and the per-row
|
/* Buttons: "Ajouter Répartition" / "Add another item", and the per-row
|
||||||
Remove/Duplicate/Collapse actions -- all plain Drupal form-submit
|
Remove/Duplicate/Collapse actions -- all plain Drupal form-submit
|
||||||
@@ -229,7 +264,7 @@ html.gin--dark-mode .figli-ledger-form {
|
|||||||
color: var(--flform-accent);
|
color: var(--flform-accent);
|
||||||
}
|
}
|
||||||
.figli-ledger-form .field-add-more-submit {
|
.figli-ledger-form .field-add-more-submit {
|
||||||
margin-top: 0.4rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
.figli-ledger-form .paragraphs-dropdown-toggle {
|
.figli-ledger-form .paragraphs-dropdown-toggle {
|
||||||
border: none;
|
border: none;
|
||||||
@@ -267,6 +302,164 @@ html.gin--dark-mode .figli-ledger-form {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==== Répartition : compactage du widget (une ligne = Compte + Montant,
|
||||||
|
rien d'autre) + assist (PLAN-repartition-assistee.md) ==== */
|
||||||
|
|
||||||
|
/* Une ligne de répartition ne doit montrer que ses deux champs. Le
|
||||||
|
titre "Répartition" de chaque ligne est redondant (le titre du champ
|
||||||
|
est déjà en thead), le bouton Collapse n'a pas de sens (edit_mode:
|
||||||
|
open, les lignes sont toujours ouvertes -- et le CSS les masque de
|
||||||
|
toute façon côté JS), et paragraph-info/paragraph-summary sont rendus
|
||||||
|
vides pour ce bundle : chacun coûtait une ligne de bruit vertical. */
|
||||||
|
.figli-ledger-form .field--name-field-repartition .paragraph-type,
|
||||||
|
.figli-ledger-form .field--name-field-repartition .paragraph-info,
|
||||||
|
.figli-ledger-form .field--name-field-repartition .paragraph-summary,
|
||||||
|
.figli-ledger-form .field--name-field-repartition .paragraphs-icon-button-collapse {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Le "paragraph-top" ne porte plus que le menu d'actions de la ligne
|
||||||
|
(Remove) : en ABSOLU dans le coin haut-droit de la ligne (td en
|
||||||
|
position:relative), aligné sur le padding horizontal de la cellule --
|
||||||
|
il n'ajoute donc AUCUNE hauteur, la ligne se réduit à ses deux
|
||||||
|
champs. */
|
||||||
|
.figli-ledger-form .field--name-field-repartition tr.paragraph-type--repartition > td {
|
||||||
|
position: relative;
|
||||||
|
/* Aucun padding vertical sur les lignes de répartition (demande
|
||||||
|
explicite) : la hauteur de ligne vient uniquement des champs, le
|
||||||
|
bordure-top reste comme séparateur. */
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
.figli-ledger-form .field--name-field-repartition .paragraph-top {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.15rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.figli-ledger-form .field--name-field-repartition .paragraph-top .paragraphs-actions {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Colonne "Order" (poids des lignes) des tables multi-valeurs : l'ordre
|
||||||
|
ne compte jamais ici (cf. handles de drag déjà masqués plus haut),
|
||||||
|
deux champs de largeur récupérés. */
|
||||||
|
.figli-ledger-form .field-multiple-table thead th:last-child,
|
||||||
|
.figli-ledger-form .field-multiple-table td.delta-order {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu "Toggle Actions" du thead : il porte le bouton "Collapse / Edit
|
||||||
|
all" (feature rétablie par update_8016) -- seul le mode "Drag & drop"
|
||||||
|
y est masqué, un réordonnancement sans objet (handles masqués plus
|
||||||
|
haut). Le menu par ligne (Remove) est hors de ce sélecteur. */
|
||||||
|
.figli-ledger-form .field--name-field-repartition thead input[name="field_repartition_dragdrop_mode"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Barre de titre du widget : titre "Répartition" À GAUCHE, écart AU
|
||||||
|
MILIEU, menu trois-points À DROITE (l'ordre DOM est titre, actions,
|
||||||
|
puis l'écart injecté par JS, d'où les order). */
|
||||||
|
.figli-ledger-form .field--name-field-repartition th.field-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.figli-ledger-form .field--name-field-repartition th.field-label h4 {
|
||||||
|
order: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.figli-ledger-form .figli-repartition-ecart {
|
||||||
|
order: 1;
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--flform-label);
|
||||||
|
}
|
||||||
|
.figli-ledger-form .field--name-field-repartition th.field-label .paragraphs-actions {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
.figli-ledger-form .figli-repartition-ecart-val {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.figli-ledger-form .figli-repartition-ecart.is-ok .figli-repartition-ecart-val {
|
||||||
|
color: var(--flform-accent);
|
||||||
|
}
|
||||||
|
.figli-ledger-form .figli-repartition-ecart.is-ko .figli-repartition-ecart-val {
|
||||||
|
color: var(--flform-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Une ligne par répartition : "Compte [input] Montant (€) [input]".
|
||||||
|
Le piège : le form-item de Compte n'a PAS l'input pour enfant direct
|
||||||
|
-- claro l'emballe dans div.claro-autocomplete (le Montant, lui, a
|
||||||
|
l'input direct). Sans flex/min-width sur ce wrapper, l'input
|
||||||
|
(size=60) force sa largeur native, pousse le Montant hors de la
|
||||||
|
cellule et les deux champs disparaissent : chaque niveau doit être
|
||||||
|
flex et compressible. */
|
||||||
|
.figli-ledger-form .paragraphs-subform .form-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.figli-ledger-form .paragraphs-subform .form-item__label {
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.figli-ledger-form .paragraphs-subform .claro-autocomplete {
|
||||||
|
display: block;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* L'input du Compte (size="60", ~420px de largeur intrinsèque) : la
|
||||||
|
chaîne flex/min-width ne suffit pas à le contenir de façon fiable à
|
||||||
|
tous les niveaux -- contrainte dure en pourcentage de son wrapper à
|
||||||
|
la place : physiquement incapable de déborder sur la colonne du
|
||||||
|
Montant, quoi que dise l'attribut size. */
|
||||||
|
.figli-ledger-form .paragraphs-subform .claro-autocomplete input.form-element {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs visibles au repos (fond opaque + bordure franche, les deux
|
||||||
|
modes via --flform-bg) et compressibles. flex:1 s'applique : au
|
||||||
|
Montant comme item direct du form-item, au Compte comme item du
|
||||||
|
claro-autocomplete flex ci-dessus. */
|
||||||
|
.figli-ledger-form .paragraphs-subform input.form-element {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
width: auto;
|
||||||
|
max-width: none;
|
||||||
|
background: var(--flform-bg);
|
||||||
|
border: 1px solid var(--flform-border);
|
||||||
|
color: var(--flform-text);
|
||||||
|
-webkit-text-fill-color: var(--flform-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Marqueur "figé" (ledger-form.js) : une valeur que l'assist ne
|
||||||
|
touchera plus -- saisie manuelle, ou chargée de la base. En item flex
|
||||||
|
(le form-item est en ligne ci-dessus), il suit l'input ; pointillé =
|
||||||
|
même convention que la référence bancaire, "valeur à regarder, pas à
|
||||||
|
retaper". */
|
||||||
|
.figli-ledger-form .figli-repartition-locked::after {
|
||||||
|
content: "figé";
|
||||||
|
flex: none;
|
||||||
|
align-self: center;
|
||||||
|
font-size: 0.64rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.16rem 0.28rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px dashed var(--flform-border);
|
||||||
|
color: var(--flform-label);
|
||||||
|
}
|
||||||
|
|
||||||
/* A répartition row flagged by the sum-mismatch #validate error (see
|
/* A répartition row flagged by the sum-mismatch #validate error (see
|
||||||
figli_compta_ledger_validate_repartition()) -- kept subtle (a red
|
figli_compta_ledger_validate_repartition()) -- kept subtle (a red
|
||||||
outline, not a solid fill) to match the same red-liseré convention
|
outline, not a solid fill) to match the same red-liseré convention
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/*
|
||||||
|
* Page de résultat d'import de relevé (templates/figli-compta-releve-import-result.html.twig).
|
||||||
|
*
|
||||||
|
* Piège #1 du CLAUDE.md respecté : le mode sombre Gin (html.gin--dark-mode)
|
||||||
|
* ne résout pas les CSS custom properties de Gin de façon fiable sur ces
|
||||||
|
* routes custom — couleurs explicites en local (--figli-*) + bloc dark mode
|
||||||
|
* dédié, jamais de fallback seul.
|
||||||
|
*/
|
||||||
|
.figli-releve-result {
|
||||||
|
max-width: 60rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem 1.5rem 3rem;
|
||||||
|
color: #161616;
|
||||||
|
--figli-border: #d4d4d4;
|
||||||
|
--figli-bg: #ffffff;
|
||||||
|
--figli-bg-soft: #f6f6f6;
|
||||||
|
--figli-ok: #1b5e20;
|
||||||
|
--figli-ok-bg: #e8f5e9;
|
||||||
|
--figli-alert: #b71c1c;
|
||||||
|
--figli-alert-bg: #ffebee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-result h2 {
|
||||||
|
margin-top: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-tag .figli-flag-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: #7a5c00;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 1.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-stat {
|
||||||
|
flex: 1 1 10rem;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
border: 1px solid var(--figli-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--figli-bg);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-stat-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #5f5f5f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-totals {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.5rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-totals th,
|
||||||
|
.figli-releve-totals td {
|
||||||
|
padding: 0.4rem 0.8rem 0.4rem 0;
|
||||||
|
border-bottom: 1px solid var(--figli-border);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-amount {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-ok {
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
border-left: 3px solid var(--figli-ok);
|
||||||
|
background: var(--figli-ok-bg);
|
||||||
|
color: var(--figli-ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-alert {
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
border-left: 3px solid var(--figli-alert);
|
||||||
|
background: var(--figli-alert-bg);
|
||||||
|
color: var(--figli-alert);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-errors li {
|
||||||
|
color: var(--figli-alert);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-dup-wrap {
|
||||||
|
max-height: 20rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--figli-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--figli-bg-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-dups {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-dups th,
|
||||||
|
.figli-releve-dups td {
|
||||||
|
padding: 0.35rem 0.8rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--figli-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-actions {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mode sombre Gin — mêmes règles, palette inversée, cf. CLAUDE.md piège #1. */
|
||||||
|
html.gin--dark-mode .figli-releve-result {
|
||||||
|
color: #e6e6e6;
|
||||||
|
--figli-border: #3a3a3a;
|
||||||
|
--figli-bg: #1c1c1c;
|
||||||
|
--figli-bg-soft: #232323;
|
||||||
|
--figli-ok: #9ee493;
|
||||||
|
--figli-ok-bg: #123016;
|
||||||
|
--figli-alert: #ff8a80;
|
||||||
|
--figli-alert-bg: #3a1212;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.gin--dark-mode .figli-releve-stat-label {
|
||||||
|
color: #a3a3a3;
|
||||||
|
}
|
||||||
@@ -51,6 +51,33 @@ function figli_compta_ledger_install() {
|
|||||||
_figli_compta_ledger_create_vocabulary('flag', 'Signalement', []);
|
_figli_compta_ledger_create_vocabulary('flag', 'Signalement', []);
|
||||||
_figli_compta_ledger_create_paragraph_repartition();
|
_figli_compta_ledger_create_paragraph_repartition();
|
||||||
_figli_compta_ledger_create_node_type_ligne_comptable();
|
_figli_compta_ledger_create_node_type_ligne_comptable();
|
||||||
|
// Fresh installs never run hook_update_N below the current schema
|
||||||
|
// version -- the import's dedup index is created here directly, and
|
||||||
|
// existing sites get it from figli_compta_ledger_update_8012().
|
||||||
|
_figli_compta_ledger_ensure_fitid_index();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index on the bank statement import's dedup fingerprint column:
|
||||||
|
* ReleveUploadForm::submitForm() runs a grouped COUNT with
|
||||||
|
* WHERE field_import_fitid_value IN (...) on every upload. Negligible
|
||||||
|
* at ~1500 lines today, but that table only ever grows, and this keeps
|
||||||
|
* the lookup off a full scan without depending on the optimizer.
|
||||||
|
*/
|
||||||
|
function _figli_compta_ledger_ensure_fitid_index() {
|
||||||
|
$schema = \Drupal::database()->schema();
|
||||||
|
if ($schema->tableExists('node__field_import_fitid')
|
||||||
|
&& !$schema->indexExists('node__field_import_fitid', 'field_import_fitid_value')) {
|
||||||
|
// MySQL's addIndex() needs the column's field specification to
|
||||||
|
// normalize the index (utf8mb4 key-length check); varchar(64) stays
|
||||||
|
// under the 191-char shortening threshold, so the index covers the
|
||||||
|
// whole fingerprint column.
|
||||||
|
$schema->addIndex('node__field_import_fitid', 'field_import_fitid_value', ['field_import_fitid_value'], [
|
||||||
|
'fields' => [
|
||||||
|
'field_import_fitid_value' => ['type' => 'varchar', 'length' => 64, 'not null' => FALSE],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _figli_compta_ledger_create_vocabulary($vid, $name, array $terms) {
|
function _figli_compta_ledger_create_vocabulary($vid, $name, array $terms) {
|
||||||
@@ -231,6 +258,12 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
|
|||||||
// filter and its future server-side equivalent can filter on a real
|
// filter and its future server-side equivalent can filter on a real
|
||||||
// stored value instead of resolving répartition paragraphs per row.
|
// stored value instead of resolving répartition paragraphs per row.
|
||||||
_figli_field('node', 'ligne_comptable', 'field_ecart', 'Écart', 'decimal', ['precision' => 12, 'scale' => 2]);
|
_figli_field('node', 'ligne_comptable', 'field_ecart', 'Écart', 'decimal', ['precision' => 12, 'scale' => 2]);
|
||||||
|
// Bank statement import (see src/Import/): dedup fingerprint per
|
||||||
|
// transaction, and the immutable bank amount for audit. No form/display
|
||||||
|
// widget for either -- same "hidden technical field" treatment as
|
||||||
|
// field_ecart before its dashboard treatment (update_8009).
|
||||||
|
_figli_field('node', 'ligne_comptable', 'field_import_fitid', 'Empreinte import relevé', 'string', ['max_length' => 64]);
|
||||||
|
_figli_field('node', 'ligne_comptable', 'field_montant_releve', 'Montant relevé bancaire (€)', 'decimal', ['precision' => 12, 'scale' => 2]);
|
||||||
_figli_field('node', 'ligne_comptable', 'field_notes', 'Notes / détail', 'string_long');
|
_figli_field('node', 'ligne_comptable', 'field_notes', 'Notes / détail', 'string_long');
|
||||||
|
|
||||||
// Free-tagging signalement (e.g. "client impayé", "à relancer") -- purely
|
// Free-tagging signalement (e.g. "client impayé", "à relancer") -- purely
|
||||||
@@ -267,17 +300,22 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
|
|||||||
->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 5])
|
->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 5])
|
||||||
->setComponent('field_cotisation_urssaf', ['type' => 'number', 'weight' => 6])
|
->setComponent('field_cotisation_urssaf', ['type' => 'number', 'weight' => 6])
|
||||||
->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 8])
|
->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 8])
|
||||||
// field_tva sits *after* Montant TTC, not between Cotisation and
|
// Read-only bank reference directly under Montant TTC (the widget
|
||||||
// TTC -- figli_compta_ledger_form_alter() inserts a non-field
|
// is #disabled by figli_compta_ledger_form_alter()) -- see
|
||||||
// "field_tva_rate" select at weight 7 (a select of the official
|
// update_8013/_8014 for why this lives on the form despite being
|
||||||
// French VAT rates) to fill that visual slot instead; this real
|
// import-written only, and why it sits at weight 9.
|
||||||
// field only becomes visible (still at its own weight, on its own
|
->setComponent('field_montant_releve', ['type' => 'number', 'weight' => 9])
|
||||||
|
// field_tva sits *after* Montant TTC (and the bank reference), not
|
||||||
|
// between Cotisation and TTC -- figli_compta_ledger_form_alter()
|
||||||
|
// inserts a non-field "field_tva_rate" select at weight 7 (a
|
||||||
|
// select of the official French VAT rates) to fill that visual
|
||||||
|
// slot instead; this real field only becomes visible (on its own
|
||||||
// full-width row) when "Autre" is picked there. See
|
// full-width row) when "Autre" is picked there. See
|
||||||
// css/ledger-form.css's grid-column rules for both.
|
// css/ledger-form.css's grid-column rules for both.
|
||||||
->setComponent('field_tva', ['type' => 'number', 'weight' => 9])
|
->setComponent('field_tva', ['type' => 'number', 'weight' => 10])
|
||||||
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 10, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']])
|
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 11, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button', 'features' => ['collapse_edit_all' => 'collapse_edit_all']]])
|
||||||
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 11])
|
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 12])
|
||||||
->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 12])
|
->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 13])
|
||||||
->save();
|
->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,3 +845,131 @@ function figli_compta_ledger_update_8010() {
|
|||||||
|
|
||||||
return "Écart calculé pour $filled lignes (aucune nouvelle révision créée), $skipped laissées vides (pas de Montant HT).";
|
return "Écart calculé pour $filled lignes (aucune nouvelle révision créée), $skipped laissées vides (pas de Montant HT).";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the two technical fields behind the bank statement import (see
|
||||||
|
* PLAN-import-releve-bancaire.md and src/Import/):
|
||||||
|
* - field_import_fitid (string 64): per-transaction dedup fingerprint
|
||||||
|
* ('csv:<sha1(date|montant|libellé normalisé)>'), compared count-aware
|
||||||
|
* against every line already in base, all provenances combined;
|
||||||
|
* - field_montant_releve (decimal 12,2): the real bank amount, written
|
||||||
|
* once at import and never touched again by anything -- the immutable
|
||||||
|
* audit reference field_montant_ttc (a normal, recomputed field) can
|
||||||
|
* legitimately drift away from as the associate corrects HT/TVA.
|
||||||
|
*
|
||||||
|
* Neither gets a form or display widget: purely technical, same treatment
|
||||||
|
* as field_ecart (update_8009). No data to backfill -- only the import
|
||||||
|
* itself writes these.
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_update_8011() {
|
||||||
|
_figli_field('node', 'ligne_comptable', 'field_import_fitid', 'Empreinte import relevé', 'string', ['max_length' => 64]);
|
||||||
|
_figli_field('node', 'ligne_comptable', 'field_montant_releve', 'Montant relevé bancaire (€)', 'decimal', ['precision' => 12, 'scale' => 2]);
|
||||||
|
|
||||||
|
return 'Champs field_import_fitid + field_montant_releve ajoutés (import de relevé bancaire).';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the dedup index on node__field_import_fitid(field_import_fitid_value)
|
||||||
|
* -- see _figli_compta_ledger_ensure_fitid_index(). Split from update_8011
|
||||||
|
* because that one already ran when the index need was reviewed.
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_update_8012() {
|
||||||
|
_figli_compta_ledger_ensure_fitid_index();
|
||||||
|
|
||||||
|
return "Index ajouté sur node__field_import_fitid (empreintes d'import, requête de dédoublonnage).";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds field_montant_releve to the ligne_comptable form display as a
|
||||||
|
* read-only reference: the widget is #disabled by
|
||||||
|
* figli_compta_ledger_form_alter() and nothing but the bank statement
|
||||||
|
* import ever writes the field (see update_8011), but associates need to
|
||||||
|
* SEE the bank's amount while they correct HT/TVA on an imported line --
|
||||||
|
* an écart between it and the recomputed Montant TTC is a useful signal
|
||||||
|
* (grouped invoice, partial payment), not something to hide.
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_update_8013() {
|
||||||
|
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
|
||||||
|
if ($form_display && !$form_display->getComponent('field_montant_releve')) {
|
||||||
|
$form_display->setComponent('field_montant_releve', ['type' => 'number', 'weight' => 13])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'field_montant_releve visible en lecture seule sur le formulaire (référence bancaire des lignes importées).';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves field_montant_releve from the form's bottom up to weight 9,
|
||||||
|
* directly under Montant TTC (weight 8) -- the bank reference reads best
|
||||||
|
* right below the amount it gets compared against while sorting an
|
||||||
|
* imported line. Weights are plain integers (display config coerces
|
||||||
|
* fractional ones, see update_8004()'s comment), so inserting means
|
||||||
|
* renumbering the tail -- field_tva/repartition/notes/flag shift to
|
||||||
|
* 10/11/12/13, exactly the kind of renumber update_8004/_8006 did
|
||||||
|
* before. Mirrors the fresh-install weights in
|
||||||
|
* _figli_compta_ledger_create_node_type_ligne_comptable().
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_update_8014() {
|
||||||
|
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
|
||||||
|
if ($form_display) {
|
||||||
|
foreach ([
|
||||||
|
'field_montant_releve' => 9,
|
||||||
|
'field_tva' => 10,
|
||||||
|
'field_repartition' => 11,
|
||||||
|
'field_notes' => 12,
|
||||||
|
'field_flag' => 13,
|
||||||
|
] as $field_name => $weight) {
|
||||||
|
$component = $form_display->getComponent($field_name);
|
||||||
|
if ($component) {
|
||||||
|
$component['weight'] = $weight;
|
||||||
|
$form_display->setComponent($field_name, $component);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$form_display->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'field_montant_releve placé sous Montant TTC (weights décalés).';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empties the Paragraphs widget's "features" setting for
|
||||||
|
* field_repartition: with duplicate and collapse_edit_all off, the
|
||||||
|
* widget stops rendering the per-row Duplicate action and the thead's
|
||||||
|
* "Collapse / Edit all" -- pure noise on a 1-3 row répartition whose
|
||||||
|
* order never matters (see ledger-form.css's compacting rules for the
|
||||||
|
* rest: per-row title and Collapse button are hidden in CSS, they have
|
||||||
|
* no widget setting).
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_update_8015() {
|
||||||
|
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
|
||||||
|
if ($form_display) {
|
||||||
|
$component = $form_display->getComponent('field_repartition');
|
||||||
|
if ($component) {
|
||||||
|
$component['settings']['features'] = [];
|
||||||
|
$form_display->setComponent('field_repartition', $component);
|
||||||
|
$form_display->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Widget Répartition compacté (features duplicate/collapse_edit_all désactivées).';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-enables the Paragraphs widget's collapse_edit_all feature (the
|
||||||
|
* "Collapse / Edit all" action in the widget's title cell) -- removed
|
||||||
|
* along with duplicate by update_8015, asked back by the associates
|
||||||
|
* the same day. Duplicate stays off: it has no use on a 1-3 row
|
||||||
|
* répartition whose order never matters.
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_update_8016() {
|
||||||
|
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
|
||||||
|
if ($form_display) {
|
||||||
|
$component = $form_display->getComponent('field_repartition');
|
||||||
|
if ($component) {
|
||||||
|
$component['settings']['features'] = ['collapse_edit_all' => 'collapse_edit_all'];
|
||||||
|
$form_display->setComponent('field_repartition', $component);
|
||||||
|
$form_display->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Bouton "Collapse / Edit all" rétabli sur le widget Répartition (duplicate reste désactivé).';
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ dashboard:
|
|||||||
- core/drupal
|
- core/drupal
|
||||||
- figli_compta_ledger/vue
|
- 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:
|
dashboard_compte:
|
||||||
js:
|
js:
|
||||||
js/dashboard-compte.js: {}
|
js/dashboard-compte.js: {}
|
||||||
@@ -45,6 +55,11 @@ ledger_form:
|
|||||||
- core/drupal
|
- core/drupal
|
||||||
- core/once
|
- core/once
|
||||||
|
|
||||||
|
releve_import:
|
||||||
|
css:
|
||||||
|
theme:
|
||||||
|
css/releve-import.css: {}
|
||||||
|
|
||||||
admin_chrome:
|
admin_chrome:
|
||||||
css:
|
css:
|
||||||
theme:
|
theme:
|
||||||
|
|||||||
@@ -29,3 +29,11 @@ figli_compta_ledger.history:
|
|||||||
menu_name: admin
|
menu_name: admin
|
||||||
parent: system.admin
|
parent: system.admin
|
||||||
weight: -8
|
weight: -8
|
||||||
|
|
||||||
|
figli_compta_ledger.releve_import:
|
||||||
|
title: 'Importer un relevé'
|
||||||
|
description: 'Créer des lignes brouillon depuis un export CSV bancaire'
|
||||||
|
route_name: figli_compta_ledger.releve_import_form
|
||||||
|
menu_name: admin
|
||||||
|
parent: system.admin
|
||||||
|
weight: -7
|
||||||
|
|||||||
@@ -119,6 +119,37 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state,
|
|||||||
$form['field_montant_ttc']['widget'][0]['value']['#description'] = t('Calculé automatiquement à partir du montant HT et de la TVA.');
|
$form['field_montant_ttc']['widget'][0]['value']['#description'] = t('Calculé automatiquement à partir du montant HT et de la TVA.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Montant relevé bancaire: the bank statement import's immutable audit
|
||||||
|
// reference (see PLAN-import-releve-bancaire.md and src/Import/) --
|
||||||
|
// displayed as plain text, no input box at all: an editable-looking
|
||||||
|
// box for a value nobody may type wastes space and misleads. Replacing
|
||||||
|
// the widget with a #type => 'item' element is loss-proof by
|
||||||
|
// construction: an item submits nothing, and
|
||||||
|
// WidgetBase::extractFormValues() skips the field entirely when no
|
||||||
|
// value was submitted (its $key_exists check) -- the stored amount
|
||||||
|
// survives every save untouched, which is the whole point. Hidden
|
||||||
|
// entirely on lines that have no bank amount (manually entered ones).
|
||||||
|
if (isset($form['field_montant_releve'])) {
|
||||||
|
/** @var \Drupal\node\NodeInterface $entity */
|
||||||
|
$entity = $form_state->getFormObject()->getEntity();
|
||||||
|
if (!$entity->get('field_montant_releve')->isEmpty()) {
|
||||||
|
$montant = (float) $entity->get('field_montant_releve')->value;
|
||||||
|
// The widget's own #title can't be trusted here (Claro moves the
|
||||||
|
// label to its form-item wrapper and leaves an empty string on the
|
||||||
|
// input -- '' isn't caught by ??), so read the configured label.
|
||||||
|
$field_config = \Drupal\field\Entity\FieldConfig::loadByName('node', $entity->bundle(), 'field_montant_releve');
|
||||||
|
$form['field_montant_releve']['widget'] = [
|
||||||
|
'#type' => 'item',
|
||||||
|
'#title' => $field_config ? $field_config->getLabel() : t('Montant relevé bancaire (€)'),
|
||||||
|
'#markup' => '<strong class="figli-releve-value">' . number_format($montant, 2, ',', ' ') . ' €</strong>',
|
||||||
|
'#description' => t("Référence bancaire immuable — renseignée à l'import du relevé, jamais modifiée."),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$form['field_montant_releve']['#access'] = FALSE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Cotisation diffuseur URSSAF (1,1%) -- only ever relevant for
|
// Cotisation diffuseur URSSAF (1,1%) -- only ever relevant for
|
||||||
// "Entrée client" lines (the only type invoiced to a client via a
|
// "Entrée client" lines (the only type invoiced to a client via a
|
||||||
// devis; see figli_compta_ledger_update_8006()'s docblock), so both
|
// devis; see figli_compta_ledger_update_8006()'s docblock), so both
|
||||||
@@ -327,14 +358,21 @@ function figli_compta_ledger_node_form_ajax_submit(array $form, FormStateInterfa
|
|||||||
* Historical imports (drush migration scripts) deliberately preserve the
|
* Historical imports (drush migration scripts) deliberately preserve the
|
||||||
* source spreadsheets' raw data, including known répartition mismatches --
|
* source spreadsheets' raw data, including known répartition mismatches --
|
||||||
* those get surfaced as visible inconsistencies in the dashboard instead of
|
* those get surfaced as visible inconsistencies in the dashboard instead of
|
||||||
* being silently fixed. Set the 'figli_compta_ledger.skip_validation' state
|
* being silently fixed. Programmatic saves that need to bypass this check
|
||||||
* flag around such a bulk import to bypass this check *and* the Montant TTC
|
* (bulk imports, the inline-edit endpoints, the bank statement import) wrap
|
||||||
* auto-computation below; new lines entered by associates through the form
|
* their save() in the figli_compta_ledger.skip_validation_context service
|
||||||
* are never exempted from either. field_ecart is the one thing still kept
|
* (request-scoped, see \Drupal\figli_compta_ledger\SkipValidationContext).
|
||||||
* in sync even under skip_validation (see below) -- separately, a second
|
* The legacy 'figli_compta_ledger.skip_validation' *state* key still works
|
||||||
* flag ('figli_compta_ledger.skip_revision') additionally suppresses the
|
* for already-shipped migration scripts, but new code must use the service:
|
||||||
* forced-revision block, used only by figli_compta_ledger_update_8010()'s
|
* the state key is a site-wide flag, and a concurrent normal form save
|
||||||
* field_ecart backfill.
|
* hitting the same window would silently skip validation too (see
|
||||||
|
* AUDIT-2026-09-09.md §2.2). Either way this bypasses this check *and* the
|
||||||
|
* Montant TTC auto-computation below; new lines entered by associates
|
||||||
|
* through the form are never exempted from either. field_ecart is the one
|
||||||
|
* thing still kept in sync even under skip (see below) -- separately, a
|
||||||
|
* second state flag ('figli_compta_ledger.skip_revision') additionally
|
||||||
|
* suppresses the forced-revision block, used only by
|
||||||
|
* figli_compta_ledger_update_8010()'s field_ecart backfill.
|
||||||
*/
|
*/
|
||||||
function figli_compta_ledger_node_presave(NodeInterface $node) {
|
function figli_compta_ledger_node_presave(NodeInterface $node) {
|
||||||
if ($node->bundle() !== 'ligne_comptable') {
|
if ($node->bundle() !== 'ligne_comptable') {
|
||||||
@@ -365,7 +403,8 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$skip_validation = \Drupal::state()->get('figli_compta_ledger.skip_validation', FALSE);
|
$skip_validation = \Drupal::service('figli_compta_ledger.skip_validation_context')->isSkipped()
|
||||||
|
|| \Drupal::state()->get('figli_compta_ledger.skip_validation', FALSE);
|
||||||
$montant_ht = (float) $node->get('field_montant_ht')->value;
|
$montant_ht = (float) $node->get('field_montant_ht')->value;
|
||||||
|
|
||||||
if (!$skip_validation) {
|
if (!$skip_validation) {
|
||||||
@@ -474,17 +513,25 @@ 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, 'current_route' => NULL],
|
'variables' => ['can_view_history' => FALSE, 'can_import_releve' => FALSE, 'current_route' => NULL],
|
||||||
'template' => 'figli-compta-home',
|
'template' => 'figli-compta-home',
|
||||||
],
|
],
|
||||||
'figli_compta_dashboard' => [
|
'figli_compta_dashboard' => [
|
||||||
'variables' => ['current_route' => NULL],
|
'variables' => ['current_route' => NULL],
|
||||||
'template' => 'figli-compta-dashboard',
|
'template' => 'figli-compta-dashboard',
|
||||||
],
|
],
|
||||||
|
'figli_compta_dashboard_repartition' => [
|
||||||
|
'variables' => ['current_route' => NULL],
|
||||||
|
'template' => 'figli-compta-dashboard-repartition',
|
||||||
|
],
|
||||||
'figli_compta_dashboard_compte' => [
|
'figli_compta_dashboard_compte' => [
|
||||||
'variables' => ['current_route' => NULL],
|
'variables' => ['current_route' => NULL],
|
||||||
'template' => 'figli-compta-dashboard-compte',
|
'template' => 'figli-compta-dashboard-compte',
|
||||||
],
|
],
|
||||||
|
'figli_compta_releve_import_result' => [
|
||||||
|
'variables' => ['summary' => [], 'lignes_url' => NULL, 'import_url' => NULL],
|
||||||
|
'template' => 'figli-compta-releve-import-result',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,15 +550,36 @@ function figli_compta_ledger_page_attachments(array &$attachments) {
|
|||||||
$front_end_routes = [
|
$front_end_routes = [
|
||||||
'figli_compta_ledger.home',
|
'figli_compta_ledger.home',
|
||||||
'figli_compta_ledger.dashboard',
|
'figli_compta_ledger.dashboard',
|
||||||
|
'figli_compta_ledger.dashboard_repartition',
|
||||||
'figli_compta_ledger.dashboard_compte',
|
'figli_compta_ledger.dashboard_compte',
|
||||||
'figli_compta_ledger.history',
|
'figli_compta_ledger.history',
|
||||||
'figli_compta_ledger.link_entree',
|
'figli_compta_ledger.link_entree',
|
||||||
|
'figli_compta_ledger.releve_import_form',
|
||||||
|
'figli_compta_ledger.releve_import_result',
|
||||||
];
|
];
|
||||||
if (in_array(\Drupal::routeMatch()->getRouteName(), $front_end_routes, TRUE)) {
|
if (in_array(\Drupal::routeMatch()->getRouteName(), $front_end_routes, TRUE)) {
|
||||||
$attachments['#attached']['library'][] = 'figli_compta_ledger/hide_admin_chrome';
|
$attachments['#attached']['library'][] = 'figli_compta_ledger/hide_admin_chrome';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements hook_gin_ignore_sticky_form_actions().
|
||||||
|
*
|
||||||
|
* Gin's sticky action buttons (forced on whenever the core Navigation
|
||||||
|
* module is active, as here) relocate a form's primary submit into the
|
||||||
|
* Gin chrome's sticky action bar -- the very chrome this module's
|
||||||
|
* front-end routes deliberately hide ($front_end_routes in
|
||||||
|
* figli_compta_ledger_page_attachments()). Without this opt-out, the
|
||||||
|
* full-page import form ends up with no visible button at all: Gin's
|
||||||
|
* after-build moves "Importer le relevé" into the hidden bar, leaving
|
||||||
|
* only managed_file's own inline "Remove" button. The other forms of
|
||||||
|
* this module don't need it -- node forms and LinkEntreeForm are opened
|
||||||
|
* in modals, which Gin skips by itself (isModalOrOffcanvas()).
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_gin_ignore_sticky_form_actions(): array {
|
||||||
|
return ['figli_compta_ledger_releve_upload_form'];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Implements hook_help().
|
* Implements hook_help().
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
access figli ledger:
|
||||||
|
title: 'Accéder au grand livre'
|
||||||
|
description: 'Consulte les lignes comptables, les tableaux de bord et toutes les API du module (données financières de la SAS).'
|
||||||
|
restrict access: true
|
||||||
|
|
||||||
|
import ligne_comptable releve:
|
||||||
|
title: 'Importer un relevé bancaire'
|
||||||
|
description: 'Téléverse un export CSV bancaire et crée des lignes comptables brouillon à trier (crée du contenu).'
|
||||||
|
restrict access: true
|
||||||
@@ -4,7 +4,7 @@ figli_compta_ledger.home:
|
|||||||
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::home'
|
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::home'
|
||||||
_title: 'Grand livre - SAS Figures Libres'
|
_title: 'Grand livre - SAS Figures Libres'
|
||||||
requirements:
|
requirements:
|
||||||
_permission: 'access content'
|
_permission: 'access figli ledger'
|
||||||
|
|
||||||
figli_compta_ledger.dashboard:
|
figli_compta_ledger.dashboard:
|
||||||
path: '/dashboard'
|
path: '/dashboard'
|
||||||
@@ -12,7 +12,15 @@ figli_compta_ledger.dashboard:
|
|||||||
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::view'
|
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::view'
|
||||||
_title: 'Tableau de bord - SAS Figures Libres'
|
_title: 'Tableau de bord - SAS Figures Libres'
|
||||||
requirements:
|
requirements:
|
||||||
_permission: 'access content'
|
_permission: 'access figli ledger'
|
||||||
|
|
||||||
|
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 figli ledger'
|
||||||
|
|
||||||
figli_compta_ledger.dashboard_compte:
|
figli_compta_ledger.dashboard_compte:
|
||||||
path: '/dashboard/compte'
|
path: '/dashboard/compte'
|
||||||
@@ -20,7 +28,7 @@ figli_compta_ledger.dashboard_compte:
|
|||||||
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::compteView'
|
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::compteView'
|
||||||
_title: 'Tableau de bord par compte - SAS Figures Libres'
|
_title: 'Tableau de bord par compte - SAS Figures Libres'
|
||||||
requirements:
|
requirements:
|
||||||
_permission: 'access content'
|
_permission: 'access figli ledger'
|
||||||
|
|
||||||
figli_compta_ledger.history:
|
figli_compta_ledger.history:
|
||||||
path: '/lignes/historique'
|
path: '/lignes/historique'
|
||||||
@@ -42,33 +50,26 @@ figli_compta_ledger.link_entree:
|
|||||||
node:
|
node:
|
||||||
type: entity: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:
|
figli_compta_ledger.api_annees:
|
||||||
path: '/lignes/api/annees'
|
path: '/lignes/api/annees'
|
||||||
defaults:
|
defaults:
|
||||||
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::annees'
|
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::annees'
|
||||||
requirements:
|
requirements:
|
||||||
_permission: 'access content'
|
_permission: 'access figli ledger'
|
||||||
|
|
||||||
figli_compta_ledger.api_reconciliation_ouverture:
|
figli_compta_ledger.api_reconciliation_ouverture:
|
||||||
path: '/lignes/api/reconciliation-ouverture'
|
path: '/lignes/api/reconciliation-ouverture'
|
||||||
defaults:
|
defaults:
|
||||||
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::reconciliationOuverture'
|
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::reconciliationOuverture'
|
||||||
requirements:
|
requirements:
|
||||||
_permission: 'access content'
|
_permission: 'access figli ledger'
|
||||||
|
|
||||||
figli_compta_ledger.api_groupe_entree:
|
figli_compta_ledger.api_groupe_entree:
|
||||||
path: '/lignes/api/groupe/{node}'
|
path: '/lignes/api/groupe/{node}'
|
||||||
defaults:
|
defaults:
|
||||||
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::groupeEntree'
|
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::groupeEntree'
|
||||||
requirements:
|
requirements:
|
||||||
_permission: 'access content'
|
_permission: 'access figli ledger'
|
||||||
options:
|
options:
|
||||||
parameters:
|
parameters:
|
||||||
node:
|
node:
|
||||||
@@ -79,14 +80,14 @@ figli_compta_ledger.api_lignes:
|
|||||||
defaults:
|
defaults:
|
||||||
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerRowsController::index'
|
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerRowsController::index'
|
||||||
requirements:
|
requirements:
|
||||||
_permission: 'access content'
|
_permission: 'access figli ledger'
|
||||||
|
|
||||||
figli_compta_ledger.api_dashboard_stats:
|
figli_compta_ledger.api_dashboard_stats:
|
||||||
path: '/dashboard/api/stats'
|
path: '/dashboard/api/stats'
|
||||||
defaults:
|
defaults:
|
||||||
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardStatsController::stats'
|
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardStatsController::stats'
|
||||||
requirements:
|
requirements:
|
||||||
_permission: 'access content'
|
_permission: 'access figli ledger'
|
||||||
|
|
||||||
figli_compta_ledger.update_type:
|
figli_compta_ledger.update_type:
|
||||||
path: '/lignes/{node}/type'
|
path: '/lignes/{node}/type'
|
||||||
@@ -111,3 +112,19 @@ figli_compta_ledger.update_field:
|
|||||||
parameters:
|
parameters:
|
||||||
node:
|
node:
|
||||||
type: entity:node
|
type: entity:node
|
||||||
|
|
||||||
|
figli_compta_ledger.releve_import_form:
|
||||||
|
path: '/lignes/importer-releve'
|
||||||
|
defaults:
|
||||||
|
_form: '\Drupal\figli_compta_ledger\Form\ReleveUploadForm'
|
||||||
|
_title: 'Importer un relevé bancaire'
|
||||||
|
requirements:
|
||||||
|
_permission: 'import ligne_comptable releve'
|
||||||
|
|
||||||
|
figli_compta_ledger.releve_import_result:
|
||||||
|
path: '/lignes/importer-releve/resultat'
|
||||||
|
defaults:
|
||||||
|
_controller: '\Drupal\figli_compta_ledger\Controller\ReleveImportResultController::result'
|
||||||
|
_title: "Résultat de l'import du relevé"
|
||||||
|
requirements:
|
||||||
|
_permission: 'import ligne_comptable releve'
|
||||||
|
|||||||
@@ -3,3 +3,12 @@ services:
|
|||||||
class: Drupal\figli_compta_ledger\EventSubscriber\RouteSubscriber
|
class: Drupal\figli_compta_ledger\EventSubscriber\RouteSubscriber
|
||||||
tags:
|
tags:
|
||||||
- { name: event_subscriber }
|
- { name: event_subscriber }
|
||||||
|
|
||||||
|
# Request-scoped répartition-check opt-out (see the class docblock: unlike
|
||||||
|
# the historical state key, a skip held here is invisible to concurrent
|
||||||
|
# requests -- audited in AUDIT-2026-09-09.md §2.2).
|
||||||
|
figli_compta_ledger.skip_validation_context:
|
||||||
|
class: Drupal\figli_compta_ledger\SkipValidationContext
|
||||||
|
|
||||||
|
figli_compta_ledger.client_matcher:
|
||||||
|
class: Drupal\figli_compta_ledger\Import\ClientMatcher
|
||||||
|
|||||||
@@ -39,7 +39,32 @@
|
|||||||
root.querySelectorAll('.messages-list__item').forEach(handleMessage);
|
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() {
|
function init() {
|
||||||
|
liftMessages();
|
||||||
scan(document);
|
scan(document);
|
||||||
new MutationObserver(function (mutations) {
|
new MutationObserver(function (mutations) {
|
||||||
mutations.forEach(function (mutation) {
|
mutations.forEach(function (mutation) {
|
||||||
@@ -48,6 +73,16 @@
|
|||||||
if (node.classList && node.classList.contains('messages-list__item')) {
|
if (node.classList && node.classList.contains('messages-list__item')) {
|
||||||
handleMessage(node);
|
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) {
|
if (node.querySelectorAll) {
|
||||||
scan(node);
|
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
|
* @file
|
||||||
* Dashboard: charts and aggregate totals (solde par compte, chiffre
|
* Dashboard: charts and aggregate totals (chiffre d'affaires par année,
|
||||||
* d'affaires par année, répartition par type, top clients), computed
|
* répartition par type, top clients), computed server-side
|
||||||
* server-side (DashboardStatsController -- plain SQL GROUP BY, not Entity
|
* (DashboardStatsController -- plain SQL GROUP BY, not Entity API) and
|
||||||
* API) and rendered here as small dependency-free div/CSS bar charts. No
|
* rendered here as small dependency-free div/CSS bar charts. No charting
|
||||||
* charting library: this project vendors its own JS (see js/vendor/), and
|
* library: this project vendors its own JS (see js/vendor/), and a
|
||||||
* a handful of bar/line charts don't warrant pulling one in.
|
* 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) {
|
(function (Drupal, Vue) {
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -54,6 +55,12 @@
|
|||||||
items: { type: Array, required: true },
|
items: { type: Array, required: true },
|
||||||
formatValue: { type: Function, required: true },
|
formatValue: { type: Function, required: true },
|
||||||
colorFor: { type: Function, default: null },
|
colorFor: { type: Function, default: null },
|
||||||
|
// Narrower label/value columns, smaller text -- for the per-année
|
||||||
|
// small-multiples grids, where a full-width chart wouldn't fit in
|
||||||
|
// a grid card. 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: {
|
computed: {
|
||||||
hasNegative() {
|
hasNegative() {
|
||||||
@@ -79,7 +86,7 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
template:
|
template:
|
||||||
'<div class="figli-hbar-chart">' +
|
'<div class="figli-hbar-chart" :class="{\'is-compact\': compact}">' +
|
||||||
'<div class="figli-hbar-row" v-for="item in items" :key="item.label">' +
|
'<div class="figli-hbar-row" v-for="item in items" :key="item.label">' +
|
||||||
'<div class="figli-hbar-label" :title="item.label">{{ item.label }}</div>' +
|
'<div class="figli-hbar-label" :title="item.label">{{ item.label }}</div>' +
|
||||||
'<div class="figli-hbar-track" :class="{\'is-diverging\': hasNegative}">' +
|
'<div class="figli-hbar-track" :class="{\'is-diverging\': hasNegative}">' +
|
||||||
@@ -119,41 +126,8 @@
|
|||||||
'</div>',
|
'</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 = {
|
const App = {
|
||||||
components: { HBarChart, ColumnChart: VBarChart, MiniTrend },
|
components: { HBarChart, ColumnChart: VBarChart },
|
||||||
data() {
|
data() {
|
||||||
return { loading: true, error: null, stats: null };
|
return { loading: true, error: null, stats: null };
|
||||||
},
|
},
|
||||||
@@ -162,12 +136,6 @@
|
|||||||
if (!this.stats) return [];
|
if (!this.stats) return [];
|
||||||
return this.stats.annees.map((y) => ({ label: y, value: this.stats.ca_par_annee[y] || 0 }));
|
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() {
|
typeItems() {
|
||||||
if (!this.stats) return [];
|
if (!this.stats) return [];
|
||||||
return Object.entries(this.stats.total_par_type)
|
return Object.entries(this.stats.total_par_type)
|
||||||
@@ -178,12 +146,105 @@
|
|||||||
if (!this.stats) return [];
|
if (!this.stats) return [];
|
||||||
return this.stats.top_clients.map((c) => ({ label: c.client, value: c.ca }));
|
return this.stats.top_clients.map((c) => ({ label: c.client, value: c.ca }));
|
||||||
},
|
},
|
||||||
// Comptes ordered by all-time solde (richest first) -- same order
|
// Small multiples, one per year -- same source as typeItems() above
|
||||||
// as soldeParCompteItems, so the trend grid below reads as a
|
// (total_par_type_par_annee is the same node-level SQL query, just
|
||||||
// continuation of the bar chart above it rather than an unrelated
|
// also grouped by année, no extra request). Mirrors
|
||||||
// shuffle.
|
// dashboard-compte.js's typeItemsParAnnee, but that one filters a
|
||||||
comptesOrdonnes() {
|
// full row list client-side (it has one, scoped to a single
|
||||||
return this.soldeParCompteItems.map((i) => i.label);
|
// 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() {
|
totalCA() {
|
||||||
if (!this.stats) return 0;
|
if (!this.stats) return 0;
|
||||||
@@ -206,15 +267,17 @@
|
|||||||
formatEurRound(v) {
|
formatEurRound(v) {
|
||||||
return EUR_ROUND.format(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) {
|
typeColor(item) {
|
||||||
return TYPE_COLORS[item.type] || '#6b7280';
|
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() {
|
async load() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|||||||
@@ -305,10 +305,44 @@
|
|||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchYearTotals(annee) {
|
// Sums the same fields LedgerRowsController::index() rows carry into
|
||||||
const res = await fetch('/lignes/api/totaux?annee=' + encodeURIComponent(annee), { headers: { Accept: 'application/json' } });
|
// the {montant_ht, cotisation, montant_ttc, ecart, par_compte} shape
|
||||||
if (!res.ok) throw new Error('/lignes/api/totaux a répondu ' + res.status);
|
// the footer template expects.
|
||||||
return res.json();
|
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)
|
// URL hash (#compte=Maud,Bachir&type=versement,achat&annee=2023&ecarts=1&aller=2024)
|
||||||
@@ -411,7 +445,12 @@
|
|||||||
parCompte,
|
parCompte,
|
||||||
somme,
|
somme,
|
||||||
ecart,
|
ecart,
|
||||||
hasError: Math.abs(ecart) > 0.01,
|
// Visible écart = non-zero to the centime (a 0.01 mismatch is
|
||||||
|
// savable -- the blocking threshold stays at 0.01 server-side --
|
||||||
|
// but must still be shown; mirrors LedgerRowsController's
|
||||||
|
// hasError). 0.005 guards against float representation of
|
||||||
|
// stored centimes.
|
||||||
|
hasError: Math.abs(ecart) > 0.005,
|
||||||
linkable: LINKABLE_TYPES.includes(attrs.field_type_ligne),
|
linkable: LINKABLE_TYPES.includes(attrs.field_type_ligne),
|
||||||
entreeLieeIds: entreeLieeNodes.map((n) => n.id),
|
entreeLieeIds: entreeLieeNodes.map((n) => n.id),
|
||||||
entreeLieeLabels: entreeLieeNodes.map((n) => n.attributes.title || n.id),
|
entreeLieeLabels: entreeLieeNodes.map((n) => n.attributes.title || n.id),
|
||||||
@@ -1455,14 +1494,33 @@
|
|||||||
// Every toolbar filter change routes through here: with filtering
|
// Every toolbar filter change routes through here: with filtering
|
||||||
// now server-side (see fetchFilteredLignes()), there's no more
|
// now server-side (see fetchFilteredLignes()), there's no more
|
||||||
// "just recompute a client-side view" -- the currently loaded
|
// "just recompute a client-side view" -- the currently loaded
|
||||||
// window has to be re-fetched with the new filter applied. Queued
|
// window has to be re-fetched with the new filter applied. The
|
||||||
// through the same chain as loadOlder()/loadNewer() (see
|
// reload itself is queued through the same chain as loadOlder()/
|
||||||
// _queueWindowOp) since several filters can change in the same
|
// loadNewer() (see _queueWindowOp) since several filters can change
|
||||||
// tick (e.g. mounted() restoring them all from the URL hash at
|
// in the same tick (e.g. mounted() restoring them all from the URL
|
||||||
// once), and interleaving their fetches would race on `rows` the
|
// hash at once), and interleaving their fetches would race on
|
||||||
// same way parallel loadOlder()/loadNewer() calls used to.
|
// `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() {
|
onFilterChanged() {
|
||||||
this._queueWindowOp(() => this.reloadWindow().then(() => this.ensureScrollable()));
|
this._queueWindowOp(() => this.reloadWindow()).then(() => this.ensureScrollable());
|
||||||
|
this.loadCurrentYearTotals();
|
||||||
this.syncHash();
|
this.syncHash();
|
||||||
},
|
},
|
||||||
// Keeps extending the window (both directions) as long as a filter
|
// Keeps extending the window (both directions) as long as a filter
|
||||||
@@ -1581,7 +1639,7 @@
|
|||||||
if (!this.currentYear) return;
|
if (!this.currentYear) return;
|
||||||
this.currentYearLoading = true;
|
this.currentYearLoading = true;
|
||||||
try {
|
try {
|
||||||
this.currentYearTotals = await fetchYearTotals(this.currentYear);
|
this.currentYearTotals = await fetchYearTotals(this.currentYear, this.currentFilters());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.currentYearTotals = null;
|
this.currentYearTotals = null;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -136,4 +136,243 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Répartition assistée (see PLAN-repartition-assistee.md): auto-fills
|
||||||
|
* the répartition amounts so the associate never does the small
|
||||||
|
* arithmetic by hand. Purely client-side pre-filling -- the server
|
||||||
|
* stays the authority (figli_compta_ledger_validate_repartition() +
|
||||||
|
* figli_compta_ledger_node_presave() unchanged), same philosophy as
|
||||||
|
* the TTC live preview above: with JS off, the form behaves exactly
|
||||||
|
* as before.
|
||||||
|
*
|
||||||
|
* Rules (plan §Comportement):
|
||||||
|
* - a value loaded from the database, or typed by the user, is
|
||||||
|
* "figée" (pinned) and never moved again;
|
||||||
|
* - the remaining rows split (HT − somme(figées)) to the centime,
|
||||||
|
* rounding remainders carried by the later rows, 0 when the
|
||||||
|
* remainder is negative;
|
||||||
|
* - triggers: row added/removed (Paragraphs AJAX re-render ->
|
||||||
|
* behavior attach), HT change, manual amount edit;
|
||||||
|
* - live "Réparti / Écart" line in the widget's title cell: green when
|
||||||
|
* the écart is zero to the centime, red otherwise (a 0.01 écart is
|
||||||
|
* savable -- blocking stays at 0.01 server-side -- but is shown,
|
||||||
|
* same as /lignes does).
|
||||||
|
*
|
||||||
|
* "Manual vs assist" detection rests on a DOM property: assigning
|
||||||
|
* input.value programmatically fires no event, only a real user edit
|
||||||
|
* fires 'input'. Locks therefore survive the widget's full AJAX
|
||||||
|
* re-renders (every add/remove rebuilds the inputs) because they live
|
||||||
|
* in this closure, keyed by input name (deltas are stable -- drag
|
||||||
|
* handles are hidden, order never matters); the assisted{} memory is
|
||||||
|
* what makes a never-assisted value (i.e. anything loaded from the
|
||||||
|
* database) figée by construction, and keeps a stale lock harmless
|
||||||
|
* after a row deletion reindexes the deltas.
|
||||||
|
*/
|
||||||
|
Drupal.behaviors.figliLedgerRepartition = {
|
||||||
|
attach: function (context) {
|
||||||
|
// Behaviors attach on every AJAX response with the replaced
|
||||||
|
// fragment as context -- the form itself only once (which is when
|
||||||
|
// the delegated listener below is set up), any later attach is a
|
||||||
|
// Paragraphs re-render and just needs a refresh. Like the TVA
|
||||||
|
// behavior's comment explains, context may BE the form (modal) or
|
||||||
|
// a fragment INSIDE it (add/remove) -- collect all three cases,
|
||||||
|
// then dedupe through once().
|
||||||
|
var ctx = (context && (context.nodeType === 1 || context.nodeType === 9)) ? context : document;
|
||||||
|
var forms = [];
|
||||||
|
if (ctx.nodeType === 1 && ctx.matches('.figli-ledger-form')) {
|
||||||
|
forms.push(ctx);
|
||||||
|
}
|
||||||
|
if (ctx.querySelectorAll) {
|
||||||
|
Array.prototype.forEach.call(ctx.querySelectorAll('.figli-ledger-form'), function (f) {
|
||||||
|
forms.push(f);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!forms.length && ctx.nodeType === 1 && ctx.closest) {
|
||||||
|
var ancestor = ctx.closest('.figli-ledger-form');
|
||||||
|
if (ancestor) {
|
||||||
|
forms.push(ancestor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
forms.forEach(function (form) {
|
||||||
|
if (once('figli-ledger-repartition', form).length) {
|
||||||
|
form.figliLedgerRepartition = initRepartitionAssist(form);
|
||||||
|
}
|
||||||
|
if (form.figliLedgerRepartition) {
|
||||||
|
form.figliLedgerRepartition.refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function initRepartitionAssist(form) {
|
||||||
|
// input.name -> true (manual edit at some point, never move again).
|
||||||
|
var figees = {};
|
||||||
|
// input.name -> last value WE wrote (the assist's own writes).
|
||||||
|
var assisted = {};
|
||||||
|
|
||||||
|
var MONTANT_SEL = 'input[name$="[field_montant][0][value]"]';
|
||||||
|
var HT_NAME = 'field_montant_ht[0][value]';
|
||||||
|
|
||||||
|
function round2(x) {
|
||||||
|
return Math.round(x * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eur(x) {
|
||||||
|
return (x < 0 ? '-' : '') + Math.abs(x).toFixed(2).replace('.', ',') + ' €';
|
||||||
|
}
|
||||||
|
|
||||||
|
function widget() {
|
||||||
|
return form.querySelector('.field--name-field-repartition');
|
||||||
|
}
|
||||||
|
|
||||||
|
function amounts() {
|
||||||
|
var w = widget();
|
||||||
|
if (!w) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.prototype.slice.call(w.querySelectorAll(MONTANT_SEL));
|
||||||
|
}
|
||||||
|
|
||||||
|
function htValue() {
|
||||||
|
var ht = form.querySelector('[name="' + HT_NAME + '"]');
|
||||||
|
var v = ht ? parseFloat(ht.value) : NaN;
|
||||||
|
return isNaN(v) ? NaN : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The écart lives inside the table's own th.field-label ("Répartition"
|
||||||
|
// heading cell), next to the Collapse-all menu -- requested layout:
|
||||||
|
// everything the réparation needs to know, in the table's title bar.
|
||||||
|
// That puts it INSIDE the widget's AJAX re-render zone (every
|
||||||
|
// add/remove rebuilds the table), so the element gets wiped and
|
||||||
|
// recreated on each refresh -- exactly what refresh() below does.
|
||||||
|
function ecartRow() {
|
||||||
|
var existing = form.querySelector('.figli-repartition-ecart');
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
var w = widget();
|
||||||
|
var th = w ? w.querySelector('th.field-label') : null;
|
||||||
|
if (!th) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var span = document.createElement('span');
|
||||||
|
span.className = 'figli-repartition-ecart';
|
||||||
|
span.innerHTML = '<span class="figli-repartition-ecart-sum"></span> · <span class="figli-repartition-ecart-val"></span>';
|
||||||
|
th.appendChild(span);
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAssisted(input, value) {
|
||||||
|
var s = value.toFixed(2);
|
||||||
|
if (input.value !== s) {
|
||||||
|
input.value = s;
|
||||||
|
}
|
||||||
|
assisted[input.name] = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
var row = ecartRow();
|
||||||
|
var inputs = amounts();
|
||||||
|
if (row) {
|
||||||
|
// Hidden until the first répartition exists, wiped with the rest
|
||||||
|
// of the table on add/remove -- re-created on the next refresh.
|
||||||
|
row.style.display = inputs.length ? '' : 'none';
|
||||||
|
}
|
||||||
|
if (!inputs.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var ht = htValue();
|
||||||
|
|
||||||
|
// Classify + mark figées + sum their values.
|
||||||
|
var sommeFigees = 0;
|
||||||
|
var libres = [];
|
||||||
|
inputs.forEach(function (input) {
|
||||||
|
var value = input.value.trim();
|
||||||
|
var figee = !!figees[input.name]
|
||||||
|
|| (value !== '' && String(assisted[input.name]) !== value);
|
||||||
|
var item = input.closest('.form-item');
|
||||||
|
if (item) {
|
||||||
|
item.classList.toggle('figli-repartition-locked', figee);
|
||||||
|
}
|
||||||
|
if (figee) {
|
||||||
|
var v = parseFloat(value);
|
||||||
|
if (!isNaN(v)) {
|
||||||
|
sommeFigees = round2(sommeFigees + v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
libres.push(input);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Distribute: sequential split, each row gets round(restant /
|
||||||
|
// restantes), remainder carried by the later rows -- sum exact to
|
||||||
|
// the centime (a naive HT/n leaves 0,03€ of écart on 3 rows).
|
||||||
|
var restant = isNaN(ht) ? NaN : round2(ht - sommeFigees);
|
||||||
|
libres.forEach(function (input, i) {
|
||||||
|
if (isNaN(restant)) {
|
||||||
|
// No HT to distribute yet: clear our own previous writes only.
|
||||||
|
if (input.value !== '' && String(assisted[input.name]) === input.value) {
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var n = libres.length - i;
|
||||||
|
var v = restant <= 0 ? 0 : round2(restant / n);
|
||||||
|
writeAssisted(input, v);
|
||||||
|
restant = round2(restant - v);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live écart, mirroring the server exactly (same rounding, same
|
||||||
|
// 0.01 tolerance as figli_compta_ledger_node_presave()).
|
||||||
|
var somme = 0;
|
||||||
|
inputs.forEach(function (input) {
|
||||||
|
var v = parseFloat(input.value);
|
||||||
|
if (!isNaN(v)) {
|
||||||
|
somme = round2(somme + v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var ecart = isNaN(ht) ? NaN : round2(ht - somme);
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row.querySelector('.figli-repartition-ecart-sum').textContent = 'Réparti : ' + eur(somme);
|
||||||
|
var val = row.querySelector('.figli-repartition-ecart-val');
|
||||||
|
val.textContent = isNaN(ecart) ? 'Écart : —' : 'Écart : ' + eur(ecart);
|
||||||
|
// Green only when the écart is zero to the centime: a 0.01
|
||||||
|
// mismatch is still savable (the server's blocking threshold
|
||||||
|
// stays at 0.01) but it IS an écart -- signaled here in red,
|
||||||
|
// exactly as /lignes now displays it (LedgerRowsController's
|
||||||
|
// hasError). 0.005 guards against float representations of
|
||||||
|
// centime values.
|
||||||
|
row.classList.toggle('is-ok', !isNaN(ecart) && Math.abs(ecart) <= 0.005);
|
||||||
|
row.classList.toggle('is-ko', !isNaN(ecart) && Math.abs(ecart) > 0.005);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One delegated listener on the form (survives every Paragraphs
|
||||||
|
// re-render, unlike per-input listeners). Programmatic writes above
|
||||||
|
// fire no event, so any 'input' seen here IS a human edit: lock it
|
||||||
|
// -- or unlock when cleared, so an emptied row becomes free again.
|
||||||
|
form.addEventListener('input', function (e) {
|
||||||
|
var t = e.target;
|
||||||
|
if (!t.matches) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t.matches(MONTANT_SEL)) {
|
||||||
|
if (t.value.trim() === '') {
|
||||||
|
delete figees[t.name];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
figees[t.name] = true;
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
else if (t.name === HT_NAME) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { refresh: refresh };
|
||||||
|
}
|
||||||
})(Drupal, once);
|
})(Drupal, once);
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
'#can_import_releve' => $this->currentUser()->hasPermission('import ligne_comptable releve'),
|
||||||
'#current_route' => 'figli_compta_ledger.home',
|
'#current_route' => 'figli_compta_ledger.home',
|
||||||
'#attached' => [
|
'#attached' => [
|
||||||
'library' => ['figli_compta_ledger/home'],
|
'library' => ['figli_compta_ledger/home'],
|
||||||
@@ -40,7 +41,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
|
* vs versements freelance, and above all which entrées haven't been
|
||||||
* (fully) paid out yet. Complements the aggregate /dashboard above,
|
* (fully) paid out yet. Complements the aggregate /dashboard above,
|
||||||
* which mixes every compte and every type together.
|
* 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
|
// share, pre-summed per (annee, compte, type) in SQL -- backs solde
|
||||||
// par compte, both all-time and per-year (each year's own total
|
// par compte, both all-time and per-year (each year's own total
|
||||||
// already includes that year's ouverture line, so it *is* that
|
// already includes that year's ouverture line, so it *is* that
|
||||||
// year's closing balance -- same logic as
|
// year's closing balance -- same logic as home.js's
|
||||||
// LedgerStatsController::totauxAnnee()), and the per-compte type
|
// aggregateTotals()/fetchYearTotals() use for the /lignes footer),
|
||||||
// breakdown used by /dashboard/compte.
|
// and the per-compte type breakdown used by /dashboard/compte.
|
||||||
$compteQuery = $connection->select('node__field_date_ligne', 'd');
|
$compteQuery = $connection->select('node__field_date_ligne', 'd');
|
||||||
$compteQuery->innerJoin('node__field_type_ligne', 't2', 't2.entity_id = d.entity_id');
|
$compteQuery->innerJoin('node__field_type_ligne', 't2', 't2.entity_id = d.entity_id');
|
||||||
$compteQuery->innerJoin('node__field_repartition', 'r', 'r.entity_id = d.entity_id');
|
$compteQuery->innerJoin('node__field_repartition', 'r', 'r.entity_id = d.entity_id');
|
||||||
@@ -82,7 +82,11 @@ class DashboardStatsController extends ControllerBase {
|
|||||||
// --- Aggregate the node-level rows in PHP. ---
|
// --- Aggregate the node-level rows in PHP. ---
|
||||||
$caParAnnee = [];
|
$caParAnnee = [];
|
||||||
$totalParType = [];
|
$totalParType = [];
|
||||||
|
$totalParTypeParAnnee = [];
|
||||||
$caParClient = [];
|
$caParClient = [];
|
||||||
|
$caParClientParAnnee = [];
|
||||||
|
$chargeParClient = [];
|
||||||
|
$chargeParClientParAnnee = [];
|
||||||
$annees = [];
|
$annees = [];
|
||||||
foreach ($nodeRows as $row) {
|
foreach ($nodeRows as $row) {
|
||||||
$montant = $row->montant_ht !== NULL ? (float) $row->montant_ht : 0.0;
|
$montant = $row->montant_ht !== NULL ? (float) $row->montant_ht : 0.0;
|
||||||
@@ -97,6 +101,16 @@ class DashboardStatsController extends ControllerBase {
|
|||||||
$client = $row->client ?: '(sans client)';
|
$client = $row->client ?: '(sans client)';
|
||||||
$caParClient[$client] = ($caParClient[$client] ?? 0) + $montant;
|
$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)) {
|
if (!$this->isAnneeValide($row->annee)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -104,6 +118,22 @@ class DashboardStatsController extends ControllerBase {
|
|||||||
$annees[$row->annee] = TRUE;
|
$annees[$row->annee] = TRUE;
|
||||||
if ($row->type === 'entree') {
|
if ($row->type === 'entree') {
|
||||||
$caParAnnee[$row->annee] = ($caParAnnee[$row->annee] ?? 0) + $montant;
|
$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);
|
arsort($caParClient);
|
||||||
@@ -116,6 +146,43 @@ class DashboardStatsController extends ControllerBase {
|
|||||||
$topClients[] = ['client' => $client, 'ca' => round($total, 2)];
|
$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. ---
|
// --- Aggregate the répartition-level rows in PHP. ---
|
||||||
$soldeParCompte = [];
|
$soldeParCompte = [];
|
||||||
$soldeParCompteParAnnee = [];
|
$soldeParCompteParAnnee = [];
|
||||||
@@ -170,12 +237,22 @@ class DashboardStatsController extends ControllerBase {
|
|||||||
// year, which can differ year to year.
|
// year, which can differ year to year.
|
||||||
ksort($soldeParCompteParAnnee);
|
ksort($soldeParCompteParAnnee);
|
||||||
ksort($totalParTypeParCompteParAnnee);
|
ksort($totalParTypeParCompteParAnnee);
|
||||||
|
ksort($totalParTypeParAnnee);
|
||||||
|
ksort($topClientsParAnnee);
|
||||||
|
ksort($chargeParClientParAnneeList);
|
||||||
|
|
||||||
return new JsonResponse([
|
return new JsonResponse([
|
||||||
'annees' => $anneesList,
|
'annees' => $anneesList,
|
||||||
'ca_par_annee' => array_map(fn ($v) => round($v, 2), $caParAnnee),
|
'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' => 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' => $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' => array_map(fn ($v) => round($v, 2), $soldeParCompte),
|
||||||
'solde_par_compte_par_annee' => $soldeParCompteParAnnee,
|
'solde_par_compte_par_annee' => $soldeParCompteParAnnee,
|
||||||
'total_par_type_par_compte' => array_map(
|
'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
|
// 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
|
// 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
|
// 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
|
// links is ever "active" here since this history feed isn't one
|
||||||
// of them -- same as visiting it from any of the other pages'
|
// 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' => [
|
'nav' => [
|
||||||
'#type' => 'container',
|
'#type' => 'container',
|
||||||
'#attributes' => ['class' => ['figli-page-nav']],
|
'#attributes' => ['class' => ['figli-page-nav']],
|
||||||
@@ -76,9 +76,14 @@ class HistoryController extends ControllerBase {
|
|||||||
],
|
],
|
||||||
'dashboard' => [
|
'dashboard' => [
|
||||||
'#type' => 'link',
|
'#type' => 'link',
|
||||||
'#title' => $this->t('Dashboard'),
|
'#title' => $this->t('SAS'),
|
||||||
'#url' => Url::fromRoute('figli_compta_ledger.dashboard'),
|
'#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' => [
|
'dashboard_compte' => [
|
||||||
'#type' => 'link',
|
'#type' => 'link',
|
||||||
'#title' => $this->t('Par compte'),
|
'#title' => $this->t('Par compte'),
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ namespace Drupal\figli_compta_ledger\Controller;
|
|||||||
use Drupal\Core\Access\CsrfRequestHeaderAccessCheck;
|
use Drupal\Core\Access\CsrfRequestHeaderAccessCheck;
|
||||||
use Drupal\Core\Controller\ControllerBase;
|
use Drupal\Core\Controller\ControllerBase;
|
||||||
use Drupal\Core\Entity\EntityStorageException;
|
use Drupal\Core\Entity\EntityStorageException;
|
||||||
|
use Drupal\figli_compta_ledger\SkipValidationContext;
|
||||||
use Drupal\node\NodeInterface;
|
use Drupal\node\NodeInterface;
|
||||||
use Drupal\taxonomy\Entity\Term;
|
use Drupal\taxonomy\Entity\Term;
|
||||||
|
use Symfony\Component\DependencyInjection\ContainerInterface;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
@@ -23,6 +25,24 @@ use Symfony\Component\HttpFoundation\Request;
|
|||||||
*/
|
*/
|
||||||
class LedgerActionsController extends ControllerBase {
|
class LedgerActionsController extends ControllerBase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request-scoped répartition-check opt-out -- see the class docblock of
|
||||||
|
* \Drupal\figli_compta_ledger\SkipValidationContext for why this replaced
|
||||||
|
* the historical global state key here.
|
||||||
|
*
|
||||||
|
* @var \Drupal\figli_compta_ledger\SkipValidationContext
|
||||||
|
*/
|
||||||
|
protected $skipValidationContext;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public static function create(ContainerInterface $container) {
|
||||||
|
$instance = parent::create($container);
|
||||||
|
$instance->skipValidationContext = $container->get('figli_compta_ledger.skip_validation_context');
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fields editable inline from /lignes without opening the full node
|
* Fields editable inline from /lignes without opening the full node
|
||||||
* edit form -- keys are the short names the frontend sends; values are
|
* edit form -- keys are the short names the frontend sends; values are
|
||||||
@@ -96,20 +116,17 @@ class LedgerActionsController extends ControllerBase {
|
|||||||
// (from historical data, never corrected -- see figli_compta_ledger's
|
// (from historical data, never corrected -- see figli_compta_ledger's
|
||||||
// module docblock) exactly as it was. The présave check exists to
|
// module docblock) exactly as it was. The présave check exists to
|
||||||
// catch new inconsistent entries, not to block relabeling the type of
|
// catch new inconsistent entries, not to block relabeling the type of
|
||||||
// an already-migrated line, so skip it for this save only. try/finally
|
// an already-migrated line, so skip it for this save only. The
|
||||||
// guarantees the global flag clears even if save() throws for an
|
// SkipValidationContext service is request-scoped with a try/finally
|
||||||
// unrelated reason -- leaving it on would silently skip validation on
|
// inside skip(), so the check is back on the instant save() returns
|
||||||
// every other save on the site.
|
// or throws -- no global flag left hanging that a concurrent save
|
||||||
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
|
// from someone else could fall into.
|
||||||
try {
|
try {
|
||||||
$node->save();
|
$this->skipValidationContext->skip(fn () => $node->save());
|
||||||
}
|
}
|
||||||
catch (EntityStorageException $e) {
|
catch (EntityStorageException $e) {
|
||||||
return new JsonResponse(['error' => $e->getMessage()], 422);
|
return new JsonResponse(['error' => $e->getMessage()], 422);
|
||||||
}
|
}
|
||||||
finally {
|
|
||||||
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse([
|
return new JsonResponse([
|
||||||
'success' => TRUE,
|
'success' => TRUE,
|
||||||
@@ -167,17 +184,14 @@ class LedgerActionsController extends ControllerBase {
|
|||||||
// signalement changes here, montant_ht and field_repartition are
|
// signalement changes here, montant_ht and field_repartition are
|
||||||
// untouched, so skipping the répartition check for this save can
|
// untouched, so skipping the répartition check for this save can
|
||||||
// never introduce a mismatch -- it can only leave a pre-existing
|
// never introduce a mismatch -- it can only leave a pre-existing
|
||||||
// historical one exactly as it was.
|
// historical one exactly as it was. Request-scoped skip (see
|
||||||
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
|
// updateType()'s comment), no global flag.
|
||||||
try {
|
try {
|
||||||
$node->save();
|
$this->skipValidationContext->skip(fn () => $node->save());
|
||||||
}
|
}
|
||||||
catch (EntityStorageException $e) {
|
catch (EntityStorageException $e) {
|
||||||
return new JsonResponse(['error' => $e->getMessage()], 422);
|
return new JsonResponse(['error' => $e->getMessage()], 422);
|
||||||
}
|
}
|
||||||
finally {
|
|
||||||
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($field === 'client') {
|
if ($field === 'client') {
|
||||||
$newValue = $node->get('field_client')->entity ? $node->get('field_client')->entity->label() : NULL;
|
$newValue = $node->get('field_client')->entity ? $node->get('field_client')->entity->label() : NULL;
|
||||||
|
|||||||
@@ -172,7 +172,13 @@ class LedgerRowsController extends ControllerBase {
|
|||||||
'parCompte' => (object) $parCompte,
|
'parCompte' => (object) $parCompte,
|
||||||
'somme' => $somme,
|
'somme' => $somme,
|
||||||
'ecart' => $ecart,
|
'ecart' => $ecart,
|
||||||
'hasError' => abs($ecart) > 0.01,
|
// "Écart visible" == non-zero to the centime. The presave/validation
|
||||||
|
// BLOCKING threshold stays at > 0.01 (tolerated rounding noise is
|
||||||
|
// savable), but a 0.01 écart is still a real mismatch and must be
|
||||||
|
// shown in the table -- at > 0.01 here it displayed nothing at all.
|
||||||
|
// 0.005 (not 0.0) keeps float representations of stored centimes
|
||||||
|
// from misclassifying.
|
||||||
|
'hasError' => abs($ecart) > 0.005,
|
||||||
'linkable' => in_array($type, self::LINKABLE_TYPES, TRUE),
|
'linkable' => in_array($type, self::LINKABLE_TYPES, TRUE),
|
||||||
'entreeLieeIds' => array_map(fn ($n) => $n->uuid(), $entreeLieeNodes),
|
'entreeLieeIds' => array_map(fn ($n) => $n->uuid(), $entreeLieeNodes),
|
||||||
'entreeLieeLabels' => array_map(fn ($n) => $n->getTitle() ?: $n->uuid(), $entreeLieeNodes),
|
'entreeLieeLabels' => array_map(fn ($n) => $n->getTitle() ?: $n->uuid(), $entreeLieeNodes),
|
||||||
|
|||||||
@@ -5,87 +5,20 @@ namespace Drupal\figli_compta_ledger\Controller;
|
|||||||
use Drupal\Core\Controller\ControllerBase;
|
use Drupal\Core\Controller\ControllerBase;
|
||||||
use Drupal\node\NodeInterface;
|
use Drupal\node\NodeInterface;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Small aggregate endpoints backing the /lignes sliding window: the
|
* Small aggregate endpoints backing the /lignes sliding window: the
|
||||||
* row-level JSON:API fetch only ever covers a date range (see home.js), so
|
* row-level fetch only ever covers a date range (see home.js), so the
|
||||||
* neither the totals footer nor the "Année" dropdown can be computed from
|
* "Année" dropdown can't be computed from whatever's currently loaded --
|
||||||
* whatever's currently loaded -- they need their own always-accurate
|
* it needs its own always-accurate query, decoupled from the row window.
|
||||||
* queries, 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 {
|
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
|
* GET /lignes/api/annees -- distinct years, most recent first, with at
|
||||||
* least 5 lines. The threshold exists specifically to keep the handful
|
* least 5 lines. The threshold exists specifically to keep the handful
|
||||||
|
|||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\figli_compta_ledger\Controller;
|
||||||
|
|
||||||
|
use Drupal\Core\Controller\ControllerBase;
|
||||||
|
use Drupal\Core\Routing\LocalRedirectResponse;
|
||||||
|
use Drupal\Core\Url;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-import summary page: counts, the accounting control total
|
||||||
|
* (file total == created + duplicates, to the centime), the list of
|
||||||
|
* duplicates skipped for human review, and a deep link into /lignes
|
||||||
|
* filtered on this batch's "IMP AAMMJJ" tag.
|
||||||
|
*
|
||||||
|
* The numbers live one-shot in the private tempstore (written by
|
||||||
|
* ReleveImportBatch::finished(), read + purged here): the page is only
|
||||||
|
* meaningful right after an import, and every user sees their own.
|
||||||
|
*/
|
||||||
|
class ReleveImportResultController extends ControllerBase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the summary of the import that just ran.
|
||||||
|
*/
|
||||||
|
public function result() {
|
||||||
|
$store = \Drupal::service('tempstore.private')->get('figli_compta_ledger');
|
||||||
|
$summary = $store->get('releve_import_result');
|
||||||
|
if (!$summary) {
|
||||||
|
// Direct navigation (bookmark, back button long after the import):
|
||||||
|
// no numbers in memory anymore, send back to the form instead of
|
||||||
|
// showing an empty shell.
|
||||||
|
$this->messenger()->addWarning($this->t("Le résultat d'un import n'est disponible qu'immédiatement après l'import lui-même."));
|
||||||
|
return new LocalRedirectResponse(Url::fromRoute('figli_compta_ledger.releve_import_form')->toString());
|
||||||
|
}
|
||||||
|
$store->delete('releve_import_result');
|
||||||
|
|
||||||
|
$eur = fn ($x) => number_format((float) $x, 2, ',', ' ') . ' €';
|
||||||
|
$fr_date = fn ($iso) => preg_replace('/^(\d{4})-(\d{2})-(\d{2})$/', '$3/$2/$1', (string) $iso);
|
||||||
|
|
||||||
|
$view = [
|
||||||
|
'file_name' => $summary['file_name'],
|
||||||
|
'tag' => $summary['tag'],
|
||||||
|
'created' => (int) $summary['created'],
|
||||||
|
'duplicates' => (int) $summary['duplicates'],
|
||||||
|
'matched' => (int) $summary['matched'],
|
||||||
|
'unmatched' => (int) $summary['unmatched'],
|
||||||
|
'errors_count' => count($summary['errors']),
|
||||||
|
'errors' => array_map(fn ($e) => $e['libelle'] . ' — ' . $e['error'], $summary['errors']),
|
||||||
|
'duplicates_list' => array_map(fn ($d) => [
|
||||||
|
'date' => $fr_date($d['date']),
|
||||||
|
'montant' => $eur($d['montant']),
|
||||||
|
'libelle' => $d['libelle'],
|
||||||
|
], $summary['duplicates_list']),
|
||||||
|
'file_total' => $eur($summary['file_total']),
|
||||||
|
'created_total' => $eur($summary['created_total']),
|
||||||
|
'duplicates_total' => $eur($summary['duplicates_total']),
|
||||||
|
'totals_ok' => (bool) $summary['totals_ok'],
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'#theme' => 'figli_compta_releve_import_result',
|
||||||
|
'#summary' => $view,
|
||||||
|
// /lignes reads its filter state from location.hash -- the flag
|
||||||
|
// filter key is "tag" (see readHashState() in js/home.js), values
|
||||||
|
// are comma-separated flag names.
|
||||||
|
'#lignes_url' => Url::fromRoute('figli_compta_ledger.home')->toString() . '#tag=' . rawurlencode($summary['tag']),
|
||||||
|
'#import_url' => Url::fromRoute('figli_compta_ledger.releve_import_form')->toString(),
|
||||||
|
'#attached' => ['library' => ['figli_compta_ledger/releve_import']],
|
||||||
|
'#cache' => ['max-age' => 0],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -24,7 +24,11 @@ class RouteSubscriber extends RouteSubscriberBase {
|
|||||||
*/
|
*/
|
||||||
protected function alterRoutes(RouteCollection $collection) {
|
protected function alterRoutes(RouteCollection $collection) {
|
||||||
if ($route = $collection->get('system.entity_autocomplete')) {
|
if ($route = $collection->get('system.entity_autocomplete')) {
|
||||||
$route->setRequirement('_permission', 'access content');
|
// 'access figli ledger' rather than 'access content': the generic
|
||||||
|
// Authenticated role no longer holds the latter (removed 2026-09),
|
||||||
|
// and anyone entitled to see ledger autocomplete suggestions must
|
||||||
|
// be entitled to the ledger's data itself.
|
||||||
|
$route->setRequirement('_permission', 'access figli ledger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\figli_compta_ledger\Form;
|
||||||
|
|
||||||
|
use Drupal\Core\Form\FormBase;
|
||||||
|
use Drupal\Core\Form\FormStateInterface;
|
||||||
|
use Drupal\file\Entity\File;
|
||||||
|
use Drupal\figli_compta_ledger\Import\CsvReleveParser;
|
||||||
|
use Drupal\figli_compta_ledger\Import\ReleveImportBatch;
|
||||||
|
use Drupal\figli_compta_ledger\Import\ReleveTransaction;
|
||||||
|
use Drupal\taxonomy\Entity\Term;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload form for a bank statement export (CSV v1 -- see
|
||||||
|
* PLAN-import-releve-bancaire.md). Self-service for the associates: the
|
||||||
|
* file lands in the *private* filesystem (financial data), is parsed in
|
||||||
|
* memory, deduplicated count-aware against lines already in base, then
|
||||||
|
* turned into "à trier" draft lines by ReleveImportBatch. Nothing here
|
||||||
|
* writes ledger lines directly -- everything goes through the normal
|
||||||
|
* Node::save() lifecycle under SkipValidationContext.
|
||||||
|
*/
|
||||||
|
class ReleveUploadForm extends FormBase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public function getFormId(): string {
|
||||||
|
return 'figli_compta_ledger_releve_upload_form';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public function buildForm(array $form, FormStateInterface $form_state): array {
|
||||||
|
$form['releve_file'] = [
|
||||||
|
'#type' => 'managed_file',
|
||||||
|
'#title' => $this->t('Relevé bancaire (CSV)'),
|
||||||
|
'#upload_location' => 'private://releves',
|
||||||
|
// Only .csv: the OFX/CMI exports of the same account exist but
|
||||||
|
// are explicitly out of v1 scope (truncated labels / unstable
|
||||||
|
// structure -- see the plan). A clear message beats a silent
|
||||||
|
// failure for someone uploading them by mistake.
|
||||||
|
'#upload_validators' => [
|
||||||
|
'FileExtension' => ['extensions' => 'csv'],
|
||||||
|
],
|
||||||
|
'#required' => TRUE,
|
||||||
|
'#description' => $this->t('Export CSV de la banque : colonnes « Date ; Date de valeur ; Débit ; Crédit ; Libellé ; Solde » (les fichiers .ofx et .cmi ne sont pas pris en charge pour l\'instant). Chaque transaction devient une ligne « à trier » : type, répartition et HT/TVA restent à assigner à la main.'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$form['actions'] = ['#type' => 'actions'];
|
||||||
|
$form['actions']['submit'] = [
|
||||||
|
'#type' => 'submit',
|
||||||
|
'#value' => $this->t('Importer le relevé'),
|
||||||
|
'#button_type' => 'primary',
|
||||||
|
];
|
||||||
|
return $form;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*
|
||||||
|
* The idiomatic home for the parse: an invalid file is a validation
|
||||||
|
* error, rejected before anything is written (the file entity is only
|
||||||
|
* promoted in submitForm()). The parsed transactions are stashed in
|
||||||
|
* $form_state so the file is never parsed twice.
|
||||||
|
*/
|
||||||
|
public function validateForm(array &$form, FormStateInterface $form_state): void {
|
||||||
|
$fids = $form_state->getValue('releve_file');
|
||||||
|
$fids = is_array($fids) ? $fids : [];
|
||||||
|
if (!$fids) {
|
||||||
|
// #required already covers the empty case.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$file = File::load(reset($fids));
|
||||||
|
if (!$file) {
|
||||||
|
$form_state->setErrorByName('releve_file', $this->t("Le fichier téléversé n'a pas pu être retrouvé."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse (pure, no writes). A clean form error -- never a crash page
|
||||||
|
// -- for anything the parser rejects.
|
||||||
|
try {
|
||||||
|
$transactions = (new CsvReleveParser())->parse($file->getFileUri());
|
||||||
|
}
|
||||||
|
catch (\RuntimeException $e) {
|
||||||
|
$form_state->setErrorByName('releve_file', $e->getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$form_state->set('releve_fid', (int) $file->id());
|
||||||
|
$form_state->set('releve_transactions', array_map(fn ($t) => $t->toArray(), $transactions));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public function submitForm(array &$form, FormStateInterface $form_state): void {
|
||||||
|
$file = File::load($form_state->get('releve_fid'));
|
||||||
|
$transactions = array_map([ReleveTransaction::class, 'fromArray'], $form_state->get('releve_transactions') ?: []);
|
||||||
|
if (!$file || !$transactions) {
|
||||||
|
// Normally unreachable -- validateForm() blocks bad files before
|
||||||
|
// submit is reached. Defensive only.
|
||||||
|
$form_state->setErrorByName('releve_file', $this->t("Rien à importer : relancez l'upload."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the uploaded statement permanently + registered as our usage:
|
||||||
|
// it's accounting source material, cron must not garbage-collect it
|
||||||
|
// after a few hours as it would a temporary file.
|
||||||
|
$file->setPermanent();
|
||||||
|
$file->save();
|
||||||
|
\Drupal::service('file.usage')->add($file, 'figli_compta_ledger', 'releve_import', (int) $file->id());
|
||||||
|
|
||||||
|
// Count-aware dedup: how many times each fingerprint appears in this
|
||||||
|
// file (k), one grouped query for how many already exist in base
|
||||||
|
// (m, all provenances combined), quota = max(0, k − m).
|
||||||
|
$counts = [];
|
||||||
|
foreach ($transactions as $t) {
|
||||||
|
$counts[$t->fitid] = ($counts[$t->fitid] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
$db_counts = [];
|
||||||
|
if ($counts) {
|
||||||
|
$select = \Drupal::database()->select('node__field_import_fitid', 'f')
|
||||||
|
->condition('f.field_import_fitid_value', array_keys($counts), 'IN');
|
||||||
|
$select->addField('f', 'field_import_fitid_value', 'fitid');
|
||||||
|
$select->addExpression('COUNT(*)', 'n');
|
||||||
|
$select->groupBy('f.field_import_fitid_value');
|
||||||
|
foreach ($select->execute()->fetchAllKeyed() as $fitid => $n) {
|
||||||
|
$db_counts[$fitid] = (int) $n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$quotas = [];
|
||||||
|
foreach ($counts as $fitid => $k) {
|
||||||
|
$quotas[$fitid] = max(0, $k - ($db_counts[$fitid] ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// One "IMP AAMMJJ" flag term per import batch (day granularity: the
|
||||||
|
// same day's re-imports join the same lot) -- short on purpose, it
|
||||||
|
// renders as a badge in /lignes' narrow Signalement column. The
|
||||||
|
// associates sort lines through the existing signalement mechanism
|
||||||
|
// (filter + amber marker), zero new UI.
|
||||||
|
$tag = 'IMP ' . date('ymd');
|
||||||
|
$terms = \Drupal::entityTypeManager()->getStorage('taxonomy_term')
|
||||||
|
->loadByProperties(['vid' => 'flag', 'name' => $tag]);
|
||||||
|
if ($terms) {
|
||||||
|
$term = reset($terms);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$term = Term::create(['vid' => 'flag', 'name' => $tag]);
|
||||||
|
$term->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$file_total = 0.0;
|
||||||
|
$payload = [];
|
||||||
|
foreach ($transactions as $t) {
|
||||||
|
$file_total += $t->montant;
|
||||||
|
$payload[] = $t->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
batch_set([
|
||||||
|
'title' => $this->t('Import du relevé bancaire'),
|
||||||
|
'operations' => [
|
||||||
|
[
|
||||||
|
[ReleveImportBatch::class, 'operation'],
|
||||||
|
[$payload, $quotas, [
|
||||||
|
'file_name' => $file->getFilename(),
|
||||||
|
'file_total' => round($file_total, 2),
|
||||||
|
'tag' => $tag,
|
||||||
|
'flag_tid' => (int) $term->id(),
|
||||||
|
]],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'finished' => [ReleveImportBatch::class, 'finished'],
|
||||||
|
'init_message' => $this->t('Import du relevé en cours…'),
|
||||||
|
'progress_message' => $this->t('@current/@total'),
|
||||||
|
'error_message' => $this->t('L\'import a rencontré une erreur inattendue.'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Where the browser lands once the batch is done -- the result page
|
||||||
|
// reads its numbers from the private tempstore.
|
||||||
|
$form_state->setRedirect('figli_compta_ledger.releve_import_result');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\figli_compta_ledger\Import;
|
||||||
|
|
||||||
|
use Drupal\taxonomy\Entity\Term;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggests which client term a bank label refers to -- conservatively:
|
||||||
|
* an empty match a human fills in beats a wrong match nobody re-checks
|
||||||
|
* (the same philosophy as the whole import feature: pre-fill, never
|
||||||
|
* decide).
|
||||||
|
*
|
||||||
|
* Matching is word-based, never raw substring: after normalization
|
||||||
|
* (uppercase, accents removed, non-alphanumerics as separators) a
|
||||||
|
* client's full name must appear as a contiguous word *sequence* in the
|
||||||
|
* label ("OVH SAS" matches "PRLV SEPA OVH SAS TWLN…" but a hypothetical
|
||||||
|
* client "AIR" would NOT match "CLAIR" -- the v0 substring draft of this
|
||||||
|
* plan had exactly that false-positive mode for short names).
|
||||||
|
*
|
||||||
|
* Pass 1: full normalized client name as contiguous word sequence. Two
|
||||||
|
* distinct clients matching is ambiguous → empty.
|
||||||
|
* Pass 2 (only if pass 1 found nothing): a single "significant" word
|
||||||
|
* (≥ 4 chars, not a legal-form filler like SAS/SARL) that belongs to
|
||||||
|
* exactly ONE client in the whole vocabulary. Several candidate clients
|
||||||
|
* → empty. Deliberately recall-biased: a generic-but-unique word (say
|
||||||
|
* "MAISON", held by a single client) can suggest the wrong client for
|
||||||
|
* an unrelated label -- acceptable because every imported line is
|
||||||
|
* flagged and manually sorted (see ReleveImportBatch), so a wrong
|
||||||
|
* suggestion gets corrected by a human rather than trusted.
|
||||||
|
*/
|
||||||
|
final class ClientMatcher {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legal-form filler words never significant on their own.
|
||||||
|
*/
|
||||||
|
private const STOPWORDS = ['SAS', 'SARL', 'SA', 'SASU', 'EURL', 'SCI', 'SCOP', 'ASSOCIATION', 'GMBH', 'SNC'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loaded client vocabulary, shape: [['term' => Term, 'words' => string[]]].
|
||||||
|
*
|
||||||
|
* @var array|null
|
||||||
|
*/
|
||||||
|
private ?array $clients = NULL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the client term a bank label most likely refers to, or NULL
|
||||||
|
* when nothing safe can be said. Never creates a term (unlike flag
|
||||||
|
* auto-creation) -- the client vocabulary stays curated by hand.
|
||||||
|
*/
|
||||||
|
public function match(string $libelle): ?Term {
|
||||||
|
$words = $this->words($libelle);
|
||||||
|
if (!$words) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
$clients = $this->loadClients();
|
||||||
|
if (!$clients) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 1: full name as a contiguous word sequence, unique candidate.
|
||||||
|
$pass1 = [];
|
||||||
|
foreach ($clients as $client) {
|
||||||
|
if (self::containsSequence($words, $client['words'])) {
|
||||||
|
$pass1[$client['term']->id()] = $client['term'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count($pass1) === 1) {
|
||||||
|
return reset($pass1);
|
||||||
|
}
|
||||||
|
if (count($pass1) > 1) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: a significant word owned by exactly one client vocabulary.
|
||||||
|
$pass2 = [];
|
||||||
|
foreach ($clients as $client) {
|
||||||
|
foreach ($client['words'] as $word) {
|
||||||
|
if (mb_strlen($word) < 4 || in_array($word, self::STOPWORDS, TRUE)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (in_array($word, $words, TRUE)) {
|
||||||
|
$pass2[$client['term']->id()] = $client['term'];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count($pass2) === 1) {
|
||||||
|
return reset($pass2);
|
||||||
|
}
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uppercase, accent-free word tokens: "EPAU / POPSU" → ["EPAU","POPSU"].
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private function words(string $text): array {
|
||||||
|
$transliterated = \Drupal::transliteration()->transliterate($text, 'fr');
|
||||||
|
$upper = mb_strtoupper($transliterated);
|
||||||
|
$words = preg_split('/[^A-Z0-9]+/', $upper, -1, PREG_SPLIT_NO_EMPTY);
|
||||||
|
return $words === FALSE ? [] : $words;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads (once per request) every client term with its normalized words.
|
||||||
|
*/
|
||||||
|
private function loadClients(): array {
|
||||||
|
if ($this->clients !== NULL) {
|
||||||
|
return $this->clients;
|
||||||
|
}
|
||||||
|
$this->clients = [];
|
||||||
|
$terms = \Drupal::entityTypeManager()->getStorage('taxonomy_term')
|
||||||
|
->loadByProperties(['vid' => 'client']);
|
||||||
|
foreach ($terms as $term) {
|
||||||
|
$words = $this->words($term->label());
|
||||||
|
if ($words) {
|
||||||
|
$this->clients[] = ['term' => $term, 'words' => $words];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $this->clients;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether $needle appears in $haystack as a contiguous word sequence.
|
||||||
|
*/
|
||||||
|
private static function containsSequence(array $haystack, array $needle): bool {
|
||||||
|
$n = count($needle);
|
||||||
|
$h = count($haystack);
|
||||||
|
if ($n === 0 || $n > $h) {
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
for ($i = 0; $i <= $h - $n; $i++) {
|
||||||
|
for ($j = 0; $j < $n; $j++) {
|
||||||
|
if ($haystack[$i + $j] !== $needle[$j]) {
|
||||||
|
continue 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\figli_compta_ledger\Import;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the CSV export of the SAS bank account into ReleveTransaction
|
||||||
|
* objects. Built from (and verified against) the real sample in
|
||||||
|
* sources-compta/extrais de comptes/00021322002.csv: `;`-separated,
|
||||||
|
* ISO-8859-1 encoded, columns "Date;Date de valeur;Débit;Crédit;Libellé;
|
||||||
|
* Solde", dates JJ/MM/AAAA, French decimal comma, exactly one of
|
||||||
|
* Débit/Crédit filled per row. The Solde column is ignored (no balance
|
||||||
|
* reconciliation in v1).
|
||||||
|
*
|
||||||
|
* Any deviation (wrong header, unparsable date/amount, empty label) throws
|
||||||
|
* a RuntimeException with a clear, user-facing French message -- the upload
|
||||||
|
* form catches it and shows a form error, never a raw crash page. Nothing
|
||||||
|
* is written to the database from here: parsing is a pure in-memory step.
|
||||||
|
*/
|
||||||
|
final class CsvReleveParser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact header (after ISO-8859-1 → UTF-8 conversion) a file must
|
||||||
|
* carry to be considered a supported statement export.
|
||||||
|
*/
|
||||||
|
private const HEADER = ['Date', 'Date de valeur', 'Débit', 'Crédit', 'Libellé', 'Solde'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a file by URI (any stream wrapper, typically private://).
|
||||||
|
*
|
||||||
|
* @return \Drupal\figli_compta_ledger\Import\ReleveTransaction[]
|
||||||
|
* Every data row as a transaction, in file order.
|
||||||
|
*
|
||||||
|
* @throws \RuntimeException
|
||||||
|
* With a ready-to-display message when the file isn't a supported
|
||||||
|
* statement export.
|
||||||
|
*/
|
||||||
|
public function parse(string $uri): array {
|
||||||
|
$stream = @fopen($uri, 'r');
|
||||||
|
if ($stream === FALSE) {
|
||||||
|
throw new \RuntimeException("Le fichier téléversé n'a pas pu être relu depuis le stockage privé.");
|
||||||
|
}
|
||||||
|
// Confirmed ISO-8859-1 on the real sample: a naive UTF-8 read would
|
||||||
|
// corrupt every accented label -- the one truly silent bug risk of
|
||||||
|
// this parser. The filter converts as fgetcsv() reads.
|
||||||
|
stream_filter_append($stream, 'convert.iconv.ISO-8859-1/UTF-8');
|
||||||
|
|
||||||
|
// Explicit enclosure + empty $escape: PHP 8.4 deprecates relying on
|
||||||
|
// the default escape (backslash), whose legacy behavior would let a
|
||||||
|
// stray "\" in a bank label swallow the next character -- with ''
|
||||||
|
// the bank's own quotes stay the only special characters.
|
||||||
|
$header = fgetcsv($stream, NULL, ';', '"', '');
|
||||||
|
if ($header === FALSE) {
|
||||||
|
fclose($stream);
|
||||||
|
throw new \RuntimeException('Le fichier est vide.');
|
||||||
|
}
|
||||||
|
$header = array_map(fn ($h) => trim((string) $h), $header);
|
||||||
|
if ($header !== self::HEADER) {
|
||||||
|
fclose($stream);
|
||||||
|
throw new \RuntimeException('Format de fichier non reconnu. En-tête attendu : « ' . implode(';', self::HEADER) . ' ». Seul l\'export CSV de la banque est pris en charge pour l\'instant (.ofx et .cmi non encore).');
|
||||||
|
}
|
||||||
|
|
||||||
|
$transactions = [];
|
||||||
|
$line = 1;
|
||||||
|
while (($row = fgetcsv($stream, NULL, ';', '"', '')) !== FALSE) {
|
||||||
|
$line++;
|
||||||
|
// Fully blank rows are just padding at the end of some exports.
|
||||||
|
if (trim(implode('', array_map('strval', $row))) === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (count($row) < 6) {
|
||||||
|
fclose($stream);
|
||||||
|
throw new \RuntimeException("Ligne $line : nombre de colonnes inattendu (" . count($row) . ", 6 attendues).");
|
||||||
|
}
|
||||||
|
|
||||||
|
$dateRaw = trim((string) $row[0]);
|
||||||
|
if (!preg_match('/^(\d{2})\/(\d{2})\/(\d{4})$/', $dateRaw, $m) || !checkdate((int) $m[2], (int) $m[1], (int) $m[3])) {
|
||||||
|
fclose($stream);
|
||||||
|
throw new \RuntimeException("Ligne $line : date « $dateRaw » invalide (JJ/MM/AAAA attendu).");
|
||||||
|
}
|
||||||
|
$date = $m[3] . '-' . $m[2] . '-' . $m[1];
|
||||||
|
|
||||||
|
$debit = trim((string) $row[2]);
|
||||||
|
$credit = trim((string) $row[3]);
|
||||||
|
if ($debit !== '' && $credit !== '') {
|
||||||
|
fclose($stream);
|
||||||
|
throw new \RuntimeException("Ligne $line : Débit et Crédit renseignés simultanément, format inattendu.");
|
||||||
|
}
|
||||||
|
// A Débit is money out whatever sign the bank exported it with
|
||||||
|
// (the sample already stores it negative; -abs() normalizes any
|
||||||
|
// sibling export that doesn't), a Crédit is money in.
|
||||||
|
if ($debit !== '') {
|
||||||
|
$montant = -abs($this->parseAmount($debit, $line));
|
||||||
|
}
|
||||||
|
elseif ($credit !== '') {
|
||||||
|
$montant = abs($this->parseAmount($credit, $line));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
fclose($stream);
|
||||||
|
throw new \RuntimeException("Ligne $line : ni Débit ni Crédit renseigné.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$libelle = self::normalizeLibelle((string) $row[4]);
|
||||||
|
if ($libelle === '') {
|
||||||
|
fclose($stream);
|
||||||
|
throw new \RuntimeException("Ligne $line : libellé vide, impossible de tracer la transaction.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$transactions[] = new ReleveTransaction(
|
||||||
|
$date,
|
||||||
|
$montant,
|
||||||
|
$libelle,
|
||||||
|
self::fitid($date, $montant, $libelle),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fclose($stream);
|
||||||
|
|
||||||
|
if (!$transactions) {
|
||||||
|
throw new \RuntimeException("Aucune transaction trouvée dans le fichier (en-tête seul).");
|
||||||
|
}
|
||||||
|
return $transactions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fingerprint of one transaction: 'csv:' + sha1(date | signed amount to
|
||||||
|
* the centime | whitespace-normalized label). No case-folding -- two
|
||||||
|
* exports of the same account reproduce labels byte for byte, and the
|
||||||
|
* fingerprint must stay stable for the count-aware dedup to recognize
|
||||||
|
* an already-imported transaction years later.
|
||||||
|
*
|
||||||
|
* The amount goes in as a fixed 2-decimal string ("−1234.56") so no
|
||||||
|
* floating-point representation ever enters the hash.
|
||||||
|
*/
|
||||||
|
public static function fitid(string $date, float $montant, string $normalizedLibelle): string {
|
||||||
|
return 'csv:' . sha1($date . '|' . number_format($montant, 2, '.', '') . '|' . $normalizedLibelle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trim + collapse internal whitespace runs to one space: stray double
|
||||||
|
* spaces would otherwise make the same transaction fingerprint
|
||||||
|
* differently across two exports of the same account.
|
||||||
|
*/
|
||||||
|
public static function normalizeLibelle(string $libelle): string {
|
||||||
|
return trim((string) preg_replace('/\s+/u', ' ', $libelle));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* French decimal ("1 234,56", "-45,89") → float, with a hard format
|
||||||
|
* check -- anything unexpected rejects the whole file with the line
|
||||||
|
* number rather than being silently coerced.
|
||||||
|
*/
|
||||||
|
private function parseAmount(string $raw, int $line): float {
|
||||||
|
$clean = str_replace([' ', "\xC2\xA0"], '', $raw);
|
||||||
|
$clean = str_replace(',', '.', $clean);
|
||||||
|
if (!preg_match('/^[+-]?\d+(\.\d+)?$/', $clean)) {
|
||||||
|
throw new \RuntimeException("Ligne $line : montant « $raw » invalide.");
|
||||||
|
}
|
||||||
|
return (float) $clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\figli_compta_ledger\Import;
|
||||||
|
|
||||||
|
use Drupal\Core\Entity\EntityStorageException;
|
||||||
|
use Drupal\node\Entity\Node;
|
||||||
|
use Drupal\taxonomy\Entity\Term;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch backend of the bank statement import: turns the parsed
|
||||||
|
* transactions into ligne_comptable nodes, ~25 per PHP-FPM request
|
||||||
|
* (several hundred transactions would blow the memory/time budget of a
|
||||||
|
* single request -- no Batch API precedent existed in this module
|
||||||
|
* before this).
|
||||||
|
*
|
||||||
|
* Count-aware dedup (see PLAN-import-releve-bancaire.md): for every
|
||||||
|
* fingerprint, the file tells how many times the transaction appears (k)
|
||||||
|
* and the database how many are already imported (m) -- the batch then
|
||||||
|
* creates max(0, k − m) lines. That imports every legitimate duplicate
|
||||||
|
* (two identical transfers the same day) while still recognizing an
|
||||||
|
* already-imported transaction across overlapping files.
|
||||||
|
*
|
||||||
|
* Every created line goes through SkipValidationContext (request-scoped,
|
||||||
|
* NOT the historical state key): répartition is deliberately empty, so
|
||||||
|
* the presave invariant sum(répartition) == montant_ht must not fire --
|
||||||
|
* field_ecart (= montant_ht) still gets computed and drives the existing
|
||||||
|
* "à trier" red marker on /lignes.
|
||||||
|
*/
|
||||||
|
final class ReleveImportBatch {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transactions processed per batch step.
|
||||||
|
*/
|
||||||
|
const CHUNK = 25;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch operation -- called repeatedly by Drupal until finished.
|
||||||
|
*
|
||||||
|
* @param array $transactions
|
||||||
|
* ReleveTransaction::toArray() payloads, in file order.
|
||||||
|
* @param array $quotas
|
||||||
|
* fitid => remaining lines to create (k − m, floored at 0).
|
||||||
|
* @param array $meta
|
||||||
|
* Immutable import metadata: file_name, file_total (sum of every
|
||||||
|
* transaction's signed amount, the control total), tag (flag term
|
||||||
|
* name), flag_tid.
|
||||||
|
* @param array $context
|
||||||
|
* Batch context (sandbox holds index + mutable quotas, results hold
|
||||||
|
* the accumulators finished() assembles the summary from).
|
||||||
|
*/
|
||||||
|
public static function operation(array $transactions, array $quotas, array $meta, array &$context): void {
|
||||||
|
if (!isset($context['sandbox']['index'])) {
|
||||||
|
$context['sandbox']['index'] = 0;
|
||||||
|
$context['sandbox']['total'] = count($transactions);
|
||||||
|
$context['sandbox']['quotas'] = $quotas;
|
||||||
|
// Seed results with the immutable import metadata (no key
|
||||||
|
// collision with the accumulators) + the zeroed accumulators.
|
||||||
|
$context['results'] += $meta + [
|
||||||
|
'created' => 0,
|
||||||
|
'duplicates' => 0,
|
||||||
|
'matched' => 0,
|
||||||
|
'unmatched' => 0,
|
||||||
|
'created_total' => 0.0,
|
||||||
|
'duplicates_total' => 0.0,
|
||||||
|
'duplicates_list' => [],
|
||||||
|
'errors' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var \Drupal\figli_compta_ledger\SkipValidationContext $skip */
|
||||||
|
$skip = \Drupal::service('figli_compta_ledger.skip_validation_context');
|
||||||
|
/** @var \Drupal\figli_compta_ledger\Import\ClientMatcher $matcher */
|
||||||
|
$matcher = \Drupal::service('figli_compta_ledger.client_matcher');
|
||||||
|
|
||||||
|
$end = min($context['sandbox']['index'] + self::CHUNK, $context['sandbox']['total']);
|
||||||
|
while ($context['sandbox']['index'] < $end) {
|
||||||
|
$t = ReleveTransaction::fromArray($transactions[$context['sandbox']['index']]);
|
||||||
|
|
||||||
|
// Count-aware dedup: quota exhausted → already in base (this many
|
||||||
|
// times), skip but surface it on the result page for human review.
|
||||||
|
if (($context['sandbox']['quotas'][$t->fitid] ?? 0) <= 0) {
|
||||||
|
$context['results']['duplicates']++;
|
||||||
|
$context['results']['duplicates_total'] += $t->montant;
|
||||||
|
$context['results']['duplicates_list'][] = [
|
||||||
|
'date' => $t->date,
|
||||||
|
'montant' => $t->montant,
|
||||||
|
'libelle' => $t->libelle,
|
||||||
|
];
|
||||||
|
$context['sandbox']['index']++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$context['sandbox']['quotas'][$t->fitid]--;
|
||||||
|
|
||||||
|
$client = $matcher->match($t->libelle);
|
||||||
|
// Sensible truncate for the required title field: the full label
|
||||||
|
// lives in field_notes, the title only backs it up as fallback
|
||||||
|
// (same libelle display rule as everywhere in /lignes).
|
||||||
|
$node = Node::create([
|
||||||
|
'type' => 'ligne_comptable',
|
||||||
|
'title' => mb_substr($t->libelle, 0, 255),
|
||||||
|
'uid' => \Drupal::currentUser()->id(),
|
||||||
|
'status' => 1,
|
||||||
|
'field_date_ligne' => $t->date,
|
||||||
|
'field_type_ligne' => 'autre',
|
||||||
|
// Immutable audit reference (written here, never again), plus
|
||||||
|
// the three "same value to start with" fields the associate
|
||||||
|
// corrects while sorting (see PLAN's HT vs TTC section).
|
||||||
|
'field_montant_releve' => $t->montant,
|
||||||
|
'field_montant_ht' => $t->montant,
|
||||||
|
'field_montant_ttc' => $t->montant,
|
||||||
|
'field_tva' => 0,
|
||||||
|
'field_notes' => $t->libelle,
|
||||||
|
'field_import_fitid' => $t->fitid,
|
||||||
|
'field_client' => $client ? $client->id() : NULL,
|
||||||
|
'field_flag' => [$meta['flag_tid']],
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
$skip->skip(fn () => $node->save());
|
||||||
|
$context['results']['created']++;
|
||||||
|
$context['results']['created_total'] += $t->montant;
|
||||||
|
$client ? $context['results']['matched']++ : $context['results']['unmatched']++;
|
||||||
|
}
|
||||||
|
catch (EntityStorageException $e) {
|
||||||
|
$context['results']['errors'][] = [
|
||||||
|
'libelle' => $t->libelle,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$context['sandbox']['index']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context['message'] = t('Import du relevé : @done/@total transactions', [
|
||||||
|
'@done' => $context['sandbox']['index'],
|
||||||
|
'@total' => $context['sandbox']['total'],
|
||||||
|
]);
|
||||||
|
$context['finished'] = $context['sandbox']['total'] > 0
|
||||||
|
? $context['sandbox']['index'] / $context['sandbox']['total']
|
||||||
|
: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch finished callback: assembles the summary the result page
|
||||||
|
* reads -- including the accounting control total (file total must
|
||||||
|
* equal created + duplicates, to the centime; if not, a parsing bug
|
||||||
|
* silently ate a line somewhere, and the page says so loudly) -- and
|
||||||
|
* stores it in the private tempstore (per-user, request-safe), where
|
||||||
|
* ReleveImportResultController picks it up once and purges it.
|
||||||
|
*/
|
||||||
|
public static function finished(bool $success, array $results, array $operations): void {
|
||||||
|
if (!$success) {
|
||||||
|
\Drupal::messenger()->addError("L'import a échoué à mi-parcours. Les transactions déjà traitées sont enregistrées ; relancez l'import du même fichier, le dédoublonnage ne recréera que ce qui manque.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$created_total = round((float) ($results['created_total'] ?? 0.0), 2);
|
||||||
|
$duplicates_total = round((float) ($results['duplicates_total'] ?? 0.0), 2);
|
||||||
|
$file_total = round((float) ($results['file_total'] ?? 0.0), 2);
|
||||||
|
|
||||||
|
$summary = [
|
||||||
|
'file_name' => (string) ($results['file_name'] ?? ''),
|
||||||
|
'tag' => (string) ($results['tag'] ?? ''),
|
||||||
|
'created' => (int) ($results['created'] ?? 0),
|
||||||
|
'duplicates' => (int) ($results['duplicates'] ?? 0),
|
||||||
|
'matched' => (int) ($results['matched'] ?? 0),
|
||||||
|
'unmatched' => (int) ($results['unmatched'] ?? 0),
|
||||||
|
'errors' => $results['errors'] ?? [],
|
||||||
|
'duplicates_list' => $results['duplicates_list'] ?? [],
|
||||||
|
'file_total' => $file_total,
|
||||||
|
'created_total' => $created_total,
|
||||||
|
'duplicates_total' => $duplicates_total,
|
||||||
|
// To the centime: every parsed transaction was either created or
|
||||||
|
// recognized as already in base. Any drift means a line vanished
|
||||||
|
// -- never expected, always announced.
|
||||||
|
'totals_ok' => abs($file_total - $created_total - $duplicates_total) < 0.005,
|
||||||
|
];
|
||||||
|
|
||||||
|
\Drupal::service('tempstore.private')->get('figli_compta_ledger')
|
||||||
|
->set('releve_import_result', $summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\figli_compta_ledger\Import;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One bank statement transaction, in the neutral shape every parser
|
||||||
|
* (CSV today, OFX/CMI maybe later) produces -- the rest of the import
|
||||||
|
* chain (dedup, client matching, node creation) only ever sees this.
|
||||||
|
*
|
||||||
|
* $date: AAAA-MM-JJ (converted from the bank format at parse time).
|
||||||
|
* $montant: signed, negative = money out (Débit), to the centime.
|
||||||
|
* $libelle: raw bank label (full, untruncated in CSV), the basis for
|
||||||
|
* client matching and the line's visible Notes.
|
||||||
|
* $fitid: dedup fingerprint, see CsvReleveParser::fitid().
|
||||||
|
*/
|
||||||
|
final class ReleveTransaction {
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $date,
|
||||||
|
public readonly float $montant,
|
||||||
|
public readonly string $libelle,
|
||||||
|
public readonly string $fitid,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain-array shape for Batch API serialization (operation args and
|
||||||
|
* sandbox are serialized between requests).
|
||||||
|
*/
|
||||||
|
public function toArray(): array {
|
||||||
|
return [
|
||||||
|
'date' => $this->date,
|
||||||
|
'montant' => $this->montant,
|
||||||
|
'libelle' => $this->libelle,
|
||||||
|
'fitid' => $this->fitid,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromArray(array $values): self {
|
||||||
|
return new self(
|
||||||
|
(string) $values['date'],
|
||||||
|
(float) $values['montant'],
|
||||||
|
(string) $values['libelle'],
|
||||||
|
(string) $values['fitid'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\figli_compta_ledger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request-scoped opt-out of the répartition invariant check in
|
||||||
|
* figli_compta_ledger_node_presave().
|
||||||
|
*
|
||||||
|
* The historical state key ('figli_compta_ledger.skip_validation') is a flag
|
||||||
|
* shared by every request on the site, stored in the database: while a
|
||||||
|
* programmatic save (inline type change, bank statement import) holds it,
|
||||||
|
* a *concurrent* normal form save happening in another PHP-FPM request would
|
||||||
|
* silently skip validation too -- exactly the kind of hole an integrity
|
||||||
|
* check must never have. This service lives in the dependency injection
|
||||||
|
* container of its own request, so a skip here is physically invisible to
|
||||||
|
* every other request; the depth counter makes nested skips safe and the
|
||||||
|
* try/finally in skip() guarantees it unwinds on exceptions as well as on
|
||||||
|
* normal completion.
|
||||||
|
*
|
||||||
|
* The state key is still honored by node_presave() for backward compatibility
|
||||||
|
* with already-shipped migration scripts, but new code (batch imports,
|
||||||
|
* future migrations) must use this service instead.
|
||||||
|
*/
|
||||||
|
final class SkipValidationContext {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current skip depth (0 = validation active).
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
private int $depth = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs $operation with the répartition-sum check disabled for this
|
||||||
|
* request only, restoring it afterwards whatever happens.
|
||||||
|
*
|
||||||
|
* @param callable $operation
|
||||||
|
* Typically fn () => $node->save().
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
* Whatever $operation returns.
|
||||||
|
*/
|
||||||
|
public function skip(callable $operation): mixed {
|
||||||
|
$this->depth++;
|
||||||
|
try {
|
||||||
|
return $operation();
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$this->depth--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the répartition-sum check is currently disabled for this
|
||||||
|
* request. Read by figli_compta_ledger_node_presave().
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
* TRUE when a skip() is currently in progress.
|
||||||
|
*/
|
||||||
|
public function isSkipped(): bool {
|
||||||
|
return $this->depth > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+2
-1
@@ -10,7 +10,8 @@
|
|||||||
#}
|
#}
|
||||||
<nav class="figli-page-nav">
|
<nav class="figli-page-nav">
|
||||||
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
|
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
|
||||||
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">Dashboard</a>
|
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">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>
|
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a>
|
||||||
</nav>
|
</nav>
|
||||||
{% verbatim %}
|
{% verbatim %}
|
||||||
|
|||||||
+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
|
Charts and aggregate totals: chiffre d'affaires par année, répartition
|
||||||
année, répartition par type, top clients. The spreadsheet-like
|
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).
|
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
|
||||||
@@ -9,7 +10,8 @@
|
|||||||
#}
|
#}
|
||||||
<nav class="figli-page-nav">
|
<nav class="figli-page-nav">
|
||||||
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
|
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
|
||||||
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">Dashboard</a>
|
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">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>
|
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a>
|
||||||
</nav>
|
</nav>
|
||||||
{% verbatim %}
|
{% verbatim %}
|
||||||
@@ -26,10 +28,6 @@
|
|||||||
<div class="figli-summary-label">CA {{ caAnneeEnCours.annee }}</div>
|
<div class="figli-summary-label">CA {{ caAnneeEnCours.annee }}</div>
|
||||||
<div class="figli-summary-value">{{ formatEurRound(caAnneeEnCours.value) }}</div>
|
<div class="figli-summary-value">{{ formatEurRound(caAnneeEnCours.value) }}</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<section class="figli-chart-section">
|
<section class="figli-chart-section">
|
||||||
@@ -38,33 +36,54 @@
|
|||||||
<column-chart :items="caParAnneeItems" :format-value="formatEurRound"></column-chart>
|
<column-chart :items="caParAnneeItems" :format-value="formatEurRound"></column-chart>
|
||||||
</section>
|
</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">
|
<section class="figli-chart-section">
|
||||||
<h2>Répartition de l'activité par type</h2>
|
<h2>Répartition de l'activité par type</h2>
|
||||||
<p class="figli-note">Montant total (HT, valeur absolue) par type de ligne, hors ouvertures.</p>
|
<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>
|
<h-bar-chart :items="typeItems" :format-value="formatEurRound" :color-for="typeColor"></h-bar-chart>
|
||||||
|
<div class="figli-year-hbar-grid">
|
||||||
|
<div class="figli-year-hbar-card" v-for="y in typeItemsParAnnee" :key="y.annee">
|
||||||
|
<div class="figli-year-hbar-title">{{ y.annee }}</div>
|
||||||
|
<h-bar-chart :items="y.items" :format-value="formatEurRound" :color-for="typeColor" compact></h-bar-chart>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="figli-chart-section">
|
<section class="figli-chart-section">
|
||||||
<h2>Top clients par chiffre d'affaires</h2>
|
<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>
|
<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>
|
<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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,18 +7,13 @@
|
|||||||
#}
|
#}
|
||||||
<nav class="figli-page-nav">
|
<nav class="figli-page-nav">
|
||||||
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
|
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
|
||||||
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">Dashboard</a>
|
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">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>
|
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a>
|
||||||
</nav>
|
</nav>
|
||||||
{% verbatim %}
|
{% verbatim %}
|
||||||
<div id="figli-home-app">
|
<div id="figli-home-app">
|
||||||
<div class="figli-toolbar">
|
<div class="figli-toolbar">
|
||||||
<a href="/node/add/ligne_comptable" class="button button--primary" @click.prevent="openAddForm">+ Ajouter une ligne</a>
|
|
||||||
{% endverbatim %}
|
|
||||||
{% if can_view_history %}
|
|
||||||
<a href="{{ path('figli_compta_ledger.history') }}" class="button">Historique</a>
|
|
||||||
{% endif %}
|
|
||||||
{% verbatim %}
|
|
||||||
|
|
||||||
<label>Compte
|
<label>Compte
|
||||||
<span class="figli-filter-row">
|
<span class="figli-filter-row">
|
||||||
@@ -264,8 +259,16 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr class="figli-totals-row">
|
<tr class="figli-totals-row">
|
||||||
<td class="actions-col"></td>
|
<td colspan="7">
|
||||||
<td colspan="6">
|
<a href="/node/add/ligne_comptable" class="button button--primary" @click.prevent="openAddForm">+ Ajouter une ligne</a>
|
||||||
|
{% endverbatim %}
|
||||||
|
{% if can_import_releve %}
|
||||||
|
<a href="{{ path('figli_compta_ledger.releve_import_form') }}" class="button">Importer un relevé</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_view_history %}
|
||||||
|
<a href="{{ path('figli_compta_ledger.history') }}" class="button">Historique</a>
|
||||||
|
{% endif %}
|
||||||
|
{% verbatim %}
|
||||||
Solde {{ currentYear || '…' }} (créditeur / débiteur)
|
Solde {{ currentYear || '…' }} (créditeur / débiteur)
|
||||||
<span v-if="currentYearLoading" class="figli-note">chargement…</span>
|
<span v-if="currentYearLoading" class="figli-note">chargement…</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -336,8 +339,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr class="figli-totals-row">
|
<tr class="figli-totals-row">
|
||||||
<td class="actions-col"></td>
|
<td colspan="6">Solde entrées et sorties liées (créditeur / débiteur)</td>
|
||||||
<td colspan="5">Solde entrées et sorties liées (créditeur / débiteur)</td>
|
|
||||||
<td class="amount">{{ drilldownTotals ? formatEur(drilldownTotals.montant_ht) : '' }}</td>
|
<td class="amount">{{ drilldownTotals ? formatEur(drilldownTotals.montant_ht) : '' }}</td>
|
||||||
<td class="amount">{{ drilldownTotals ? formatEur(drilldownTotals.montant_ttc) : '' }}</td>
|
<td class="amount">{{ drilldownTotals ? formatEur(drilldownTotals.montant_ttc) : '' }}</td>
|
||||||
<td v-for="c in modalComptes" :key="c" class="amount compte-col" :class="drilldownTotals ? soldeClass(drilldownTotals.par_compte[c]) : ''">{{ drilldownTotals && drilldownTotals.par_compte[c] !== undefined ? formatEur(drilldownTotals.par_compte[c]) : '' }}</td>
|
<td v-for="c in modalComptes" :key="c" class="amount compte-col" :class="drilldownTotals ? soldeClass(drilldownTotals.par_compte[c]) : ''">{{ drilldownTotals && drilldownTotals.par_compte[c] !== undefined ? formatEur(drilldownTotals.par_compte[c]) : '' }}</td>
|
||||||
|
|||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
{#
|
||||||
|
Résultat d'import d'un relevé bancaire (ReleveImportResultController).
|
||||||
|
Page simple sans Vue : Twig rend tout, les chiffres viennent du tempstore
|
||||||
|
privé (une seule lecture, purgée aussitôt). Mêmes classes de navigation
|
||||||
|
que les autres pages du module (figli-page-nav, admin-chrome.css).
|
||||||
|
#}
|
||||||
|
<nav class="figli-page-nav">
|
||||||
|
<a href="{{ path('figli_compta_ledger.home') }}">Grand livre</a>
|
||||||
|
<a href="{{ path('figli_compta_ledger.dashboard') }}">SAS</a>
|
||||||
|
<a href="{{ path('figli_compta_ledger.dashboard_repartition') }}">Répartition/Soldes</a>
|
||||||
|
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}">Par compte</a>
|
||||||
|
</nav>
|
||||||
|
<div class="figli-releve-result">
|
||||||
|
<h2>Import terminé — {{ summary.file_name }}</h2>
|
||||||
|
<p class="figli-releve-tag">Chaque ligne créée porte le signalement
|
||||||
|
<span class="figli-flag-badge">{{ summary.tag }}</span> et apparaît sur
|
||||||
|
<a href="{{ lignes_url }}">/lignes</a> avec un liseré rouge (répartition à faire) tant qu'elle n'a pas été triée.</p>
|
||||||
|
|
||||||
|
<div class="figli-releve-stats">
|
||||||
|
<div class="figli-releve-stat">
|
||||||
|
<span class="figli-releve-stat-value">{{ summary.created }}</span>
|
||||||
|
<span class="figli-releve-stat-label">lignes créées</span>
|
||||||
|
</div>
|
||||||
|
<div class="figli-releve-stat">
|
||||||
|
<span class="figli-releve-stat-value">{{ summary.duplicates }}</span>
|
||||||
|
<span class="figli-releve-stat-label">doublons déjà en base (ignorés)</span>
|
||||||
|
</div>
|
||||||
|
<div class="figli-releve-stat">
|
||||||
|
<span class="figli-releve-stat-value">{{ summary.matched }}</span>
|
||||||
|
<span class="figli-releve-stat-label">clients rapprochés</span>
|
||||||
|
</div>
|
||||||
|
<div class="figli-releve-stat">
|
||||||
|
<span class="figli-releve-stat-value">{{ summary.unmatched }}</span>
|
||||||
|
<span class="figli-releve-stat-label">sans client (à remplir)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Totaux de contrôle</h3>
|
||||||
|
<table class="figli-releve-totals">
|
||||||
|
<tbody>
|
||||||
|
<tr><th scope="row">Somme des mouvements du fichier</th><td class="figli-releve-amount">{{ summary.file_total }}</td></tr>
|
||||||
|
<tr><th scope="row">Somme des lignes créées</th><td class="figli-releve-amount">{{ summary.created_total }}</td></tr>
|
||||||
|
<tr><th scope="row">Somme des doublons ignorés</th><td class="figli-releve-amount">{{ summary.duplicates_total }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% if summary.totals_ok %}
|
||||||
|
<p class="figli-releve-ok">Équilibre vérifié au centime : aucun mouvement perdu ni dupliqué.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="figli-releve-alert">Anomalie de parsing : la somme des mouvements du fichier ne correspond pas à « créées + doublons ». Vérifiez le fichier source et les lignes importées avant de trier le lot.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if summary.errors_count %}
|
||||||
|
<h3>Erreurs ({{ summary.errors_count }})</h3>
|
||||||
|
<ul class="figli-releve-errors">
|
||||||
|
{% for e in summary.errors %}<li>{{ e }}</li>{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if summary.duplicates_list %}
|
||||||
|
<h3>Doublons ignorés ({{ summary.duplicates_list|length }})</h3>
|
||||||
|
<p>Ces transactions figuraient déjà en base (empreinte identique, toutes provenances confondues). Vérifiez la liste si vous attendiez un mouvement.</p>
|
||||||
|
<div class="figli-releve-dup-wrap">
|
||||||
|
<table class="figli-releve-dups">
|
||||||
|
<thead>
|
||||||
|
<tr><th scope="col">Date</th><th scope="col">Montant</th><th scope="col">Libellé</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in summary.duplicates_list %}
|
||||||
|
<tr><td>{{ d.date }}</td><td class="figli-releve-amount">{{ d.montant }}</td><td>{{ d.libelle }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="figli-releve-actions">
|
||||||
|
<a class="button button--primary" href="{{ lignes_url }}">Voir les lignes importées sur /lignes</a>
|
||||||
|
<a class="button" href="{{ import_url }}">Importer un autre relevé</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user