Compare commits

...
6 Commits
Author SHA1 Message Date
bachir 00956825fa Réduit les marges du tableau (côtés et dessous) aux 2/3
- Gin's .layout-container margin-left/right (48px) réduit à 16px, sur
  les routes de ce module (hide_admin_chrome, déjà route-scopé).
- #figli-home-app margin-bottom réduit de 1rem à 0,33rem.
- .figli-table-wrap : max-height 75vh remplacé par calc(100vh - 16rem)
  -- le pourcentage de viewport ne tenait plus compte du titre/menu +
  barre d'outils au-dessus (hauteur à peu près fixe, ~16rem), qui a
  grandi au fil des filtres ajoutés depuis le dernier fix du double
  scroll -- avait fini par redépasser ce que 75vh laissait de marge,
  réintroduisant un scroll de page en plus de celui du tableau (84px
  d'écart mesuré avant ce correctif, ~8px après -- l'essentiel de
  l'écart restant venant justement de la marge du dessous réduite
  ci-dessus, pas d'un nouveau débordement).
2026-09-07 14:54:06 +02:00
bachir 74ee8ab3b9 Badge TVA non officielle plus visible : gros liseret rouge sous le montant
Le point ambre discret n'était pas assez visible. Remplacé par un
border-bottom épais (3px, rouge) directement sur la cellule TVA --
lisible d'un coup d'œil sans avoir besoin de place horizontale
supplémentaire, contrairement à un badge texte/icône dans une colonne
de 3,5% de large.
2026-09-07 14:48:09 +02:00
bachir 6576b35c07 Badge sur les taux TVA non officiels, écart positif en vert
- Un point ambre discret (pas de texte -- la colonne TVA ne fait que
  3,5% de large, à peine assez pour "12,64 %" lui-même) marque tout
  taux TVA qui ne tombe sur aucun des 5 taux officiels français (0,
  2,1, 5,5, 10, 20 %, ±0,05 point comme dans les scripts de migration).
  Infobulle pour le détail.
- Écart positif (répartition > Montant HT) passe au vert
  (--figli-positive), au lieu du rouge uniforme précédent -- même
  logique que la colonne Montant HT, qui distingue déjà positif/négatif.
2026-09-07 14:45:06 +02:00
bachir 0baa03855b Le champ/colonne "1,1%" affiche le delta seul, pas HT + 1,1%
field_cotisation_urssaf stockait round(HT * 1.011, 2) (le montant
augmenté) -- "1,1%" comme libellé de champ/colonne désigne la
cotisation elle-même, pas HT + cotisation. Change pour
round(HT * 0.011, 2). La TVA continue de s'appliquer sur la base
augmentée (HT + ce champ) : Montant TTC est inchangé par cette
correction, seule la valeur affichée/stockée dans "1,1%" change.

Migration (update hook 8008) : recalcule field_cotisation_urssaf pour
les 154 lignes que figli_compta_ledger_update_8007() avait marquées
cotisation active, en soustrayant simplement le HT déjà correct --
contrairement à cette dernière, ce n'est pas un arbitrage sur des
données historiques ambiguës, juste un bug dans du code écrit plus tôt
le même jour, donc recalculé sans condition ni prudence particulière.

Ajoute aussi le total "1,1%" au pied du tableau (solde de l'année),
absent jusqu'ici : LedgerStatsController::totauxAnnee() somme
maintenant field_cotisation_urssaf comme il le fait déjà pour HT/TTC.
2026-09-07 14:38:10 +02:00
bachir 6c33bc0098 Cotisation diffuseur URSSAF (1,1%) : nouveau champ, TTC calculé en cascade
Architecture métier corrigée : la SAS devise HT, ajoute (quand
applicable -- pas systématique) 1,1% de cotisation diffuseur URSSAF
pour l'usage de freelances, puis applique la TVA sur ce montant
augmenté -- pas sur le HT brut. La répartition entre comptes reste
basée sur le HT seul, inchangée.

- field_cotisation_active (case à cocher, "Entrée client" uniquement,
  cochée par défaut sur une nouvelle ligne -- optionnelle puisque tous
  les devis ne l'incluent pas historiquement).
- field_cotisation_urssaf ("1,1%", montant calculé HT × 1,011, jamais
  éditable).
- Montant TTC = round(cotisation × (1 + TVA/100), 2) quand la
  cotisation s'applique, round(HT × (1 + TVA/100), 2) sinon --
  inchangé pour tous les types hors "Entrée client".

Migration (update hooks 8006/8007) : toutes les lignes "Entrée client"
de field_tva rétro-calculées par figli_compta_ledger_update_8005()
étaient fausses dès que la cotisation s'appliquait (taux mixte HT->TTC,
pas le vrai taux de TVA) -- corrigées avec la même prudence que la
migration précédente : 2021 laissée de côté (pratique non confirmée
sur cette année), et par ligne, un taux déjà "propre" (0/2,1/5,5/10/20
%) signifie qu'aucune cotisation n'a été appliquée (laissé tel quel) ;
sinon le nouveau taux recalculé n'est retenu que s'il retombe lui-même
sur un taux officiel, sinon la ligne reste inchangée plutôt que de
deviner. Montant TTC historique jamais réécrit, comme pour toute
migration de ce module. Résultat : 154 lignes corrigées (cotisation
active), 90 déjà correctes (cotisation inactive), 40 ambiguës laissées
telles quelles, 43 lignes 2021 ignorées.

Formulaire réorganisé en grille à 4 colonnes (HT | 1,1% | TVA | TTC),
case à cocher masquée hors "Entrée client" via #states réel (fiable
ici, contrairement au select TVA -- watch sur field_type_ligne, un
vrai champ, pas un élément ajouté à la main). Colonne "1,1%" ajoutée
au grand livre entre HT et TVA.
2026-09-07 14:33:20 +02:00
bachir 134c97ac9d Supprime le double scroll : menu à hauteur du titre, marge Gin retirée
Le menu Grand livre/Dashboard/Par compte passe en position fixe,
aligné avec le <h1> de la page plutôt que sur sa propre ligne en
dessous -- les deux viennent de régions Drupal différentes (le titre
du bloc sticky top-bar de Gin, le menu du contenu de la page) sans
conteneur flex/grid commun pour les aligner autrement.

Ça ne suffisait pas à éliminer le scroll de page en plus de celui du
tableau (max-height: 75vh sur .figli-table-wrap) : Gin applique un
margin-bottom: 80px sur <main class="page-content">, pensé pour une
page d'admin classique, pas pour ce layout à hauteur de viewport fixe.
Neutralisé sur les routes du module (hide_admin_chrome, déjà route-
scopé).

Au passage, corrige un oubli : figli_compta_ledger.dashboard_compte
n'était jamais dans la liste hide_admin_chrome, donc la barre d'admin
Gin restait visible sur /dashboard/compte (et donc le titre plus bas
que sur les 3 autres pages) -- ajouté.

Et sur demande complémentaire en cours de route : le même menu manquait
purement et simplement sur /lignes/historique (pas de template Twig
propre, juste un tableau brut) -- ajouté en tableau de rendu directement
dans HistoryController, mêmes classes CSS que le <nav> des autres pages.
2026-09-07 12:46:36 +02:00
17 changed files with 656 additions and 83 deletions
@@ -4,6 +4,8 @@ status: true
dependencies:
config:
- field.field.node.ligne_comptable.field_client
- field.field.node.ligne_comptable.field_cotisation_active
- field.field.node.ligne_comptable.field_cotisation_urssaf
- field.field.node.ligne_comptable.field_date_ligne
- field.field.node.ligne_comptable.field_entree_liee
- field.field.node.ligne_comptable.field_flag
@@ -40,6 +42,20 @@ content:
size: 60
placeholder: ''
third_party_settings: { }
field_cotisation_active:
type: boolean_checkbox
weight: 4
region: content
settings:
display_label: true
third_party_settings: { }
field_cotisation_urssaf:
type: number
weight: 6
region: content
settings:
placeholder: ''
third_party_settings: { }
field_date_ligne:
type: datetime_default
weight: 0
@@ -58,7 +74,7 @@ content:
third_party_settings: { }
field_flag:
type: entity_reference_autocomplete_tags
weight: 11
weight: 12
region: content
settings:
match_operator: CONTAINS
@@ -75,14 +91,14 @@ content:
third_party_settings: { }
field_montant_ttc:
type: number
weight: 7
weight: 8
region: content
settings:
placeholder: ''
third_party_settings: { }
field_notes:
type: string_textarea
weight: 10
weight: 11
region: content
settings:
rows: 5
@@ -98,7 +114,7 @@ content:
third_party_settings: { }
field_repartition:
type: paragraphs
weight: 9
weight: 10
region: content
settings:
title: Répartition
@@ -116,7 +132,7 @@ content:
third_party_settings: { }
field_tva:
type: number
weight: 8
weight: 9
region: content
settings:
placeholder: ''
@@ -4,6 +4,8 @@ status: true
dependencies:
config:
- field.field.node.ligne_comptable.field_client
- field.field.node.ligne_comptable.field_cotisation_active
- field.field.node.ligne_comptable.field_cotisation_urssaf
- field.field.node.ligne_comptable.field_date_ligne
- field.field.node.ligne_comptable.field_entree_liee
- field.field.node.ligne_comptable.field_flag
@@ -33,6 +35,27 @@ content:
third_party_settings: { }
weight: 2
region: content
field_cotisation_active:
type: boolean
label: above
settings:
format: default
format_custom_false: ''
format_custom_true: ''
third_party_settings: { }
weight: 4
region: content
field_cotisation_urssaf:
type: number_decimal
label: above
settings:
thousand_separator: ''
decimal_separator: .
scale: 2
prefix_suffix: true
third_party_settings: { }
weight: 6
region: content
field_date_ligne:
type: datetime_default
label: above
@@ -56,7 +79,7 @@ content:
settings:
link: true
third_party_settings: { }
weight: 11
weight: 12
region: content
field_montant_ht:
type: number_decimal
@@ -78,14 +101,14 @@ content:
scale: 2
prefix_suffix: true
third_party_settings: { }
weight: 7
weight: 8
region: content
field_notes:
type: basic_string
label: above
settings: { }
third_party_settings: { }
weight: 10
weight: 11
region: content
field_numero_facture:
type: string
@@ -103,7 +126,7 @@ content:
view_mode: default
link: ''
third_party_settings: { }
weight: 9
weight: 10
region: content
field_tva:
type: number_decimal
@@ -114,7 +137,7 @@ content:
scale: 2
prefix_suffix: true
third_party_settings: { }
weight: 8
weight: 9
region: content
field_type_ligne:
type: list_default
@@ -0,0 +1,23 @@
uuid: 3defc3d6-42b6-4340-a7cf-fecadb919934
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_cotisation_active
- node.type.ligne_comptable
id: node.ligne_comptable.field_cotisation_active
field_name: field_cotisation_active
entity_type: node
bundle: ligne_comptable
label: 'Cotisation diffuseur URSSAF (1,1%)'
description: ''
required: false
translatable: true
default_value:
-
value: 1
default_value_callback: ''
settings:
on_label: 'On'
off_label: 'Off'
field_type: boolean
@@ -0,0 +1,23 @@
uuid: 309c0b94-be32-42a3-b990-cb04858afb8d
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_cotisation_urssaf
- node.type.ligne_comptable
id: node.ligne_comptable.field_cotisation_urssaf
field_name: field_cotisation_urssaf
entity_type: node
bundle: ligne_comptable
label: '1,1%'
description: ''
required: false
translatable: true
default_value: { }
default_value_callback: ''
settings:
min: null
max: null
prefix: ''
suffix: ''
field_type: decimal
@@ -0,0 +1,18 @@
uuid: 28c4ae5a-011c-4f31-bca5-36874c6c182b
langcode: en
status: true
dependencies:
module:
- node
id: node.field_cotisation_active
field_name: field_cotisation_active
entity_type: node
type: boolean
settings: { }
module: core
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false
@@ -0,0 +1,20 @@
uuid: 486554c0-7686-4c52-ab33-eb09b5573be5
langcode: en
status: true
dependencies:
module:
- node
id: node.field_cotisation_urssaf
field_name: field_cotisation_urssaf
entity_type: node
type: decimal
settings:
precision: 12
scale: 2
module: core
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false
@@ -2,12 +2,28 @@
on those two custom routes, but attached here rather than duplicated
between home.css and dashboard.css -- and defined with its own explicit
colors rather than relying on either page's #figli-*-app-scoped custom
properties, since this <nav> sits outside both of those elements. */
properties, since this <nav> sits outside both of those elements.
Fixed rather than in normal flow: this <nav> and the page's <h1>
("Grand livre - SAS Figures Libres" etc.) come from two different
Drupal block regions (the title from Gin's sticky top-bar header,
this nav from the page content below it) with no shared flex/grid
container to align them side by side declaratively -- in normal flow
it rendered as its own full-width row *under* the title, costing
~75px of vertical space the table below could otherwise use before
the whole page (not just the table's own internal scroll) needed to
scroll too. Pulling it out of flow and pinning it next to the title
reclaims that space; top/right below are tuned to that title's actual
position, not derived from it, so they'd need revisiting if Gin's
header height or padding ever changes. */
.figli-page-nav {
position: fixed;
top: 0.65rem;
right: 1rem;
z-index: 50;
display: flex;
justify-content: flex-end;
gap: 0.4rem;
padding: 0.5rem 0.25rem 0;
}
.figli-page-nav a {
padding: 0.3rem 0.8rem;
@@ -26,3 +26,22 @@
.dialog-off-canvas-main-canvas {
margin-inline-start: 0 !important;
}
/* Gin's default 80px margin-bottom on <main>, meant as breathing room
below a normal admin content page -- on this app's tight,
single-viewport layout (page nav pinned next to the title, table
scrolling internally, see .figli-page-nav in admin-chrome.css) it was
the last remaining chunk of an *outer* page scroll on top of the
table's own, since max-height: 75vh on .figli-table-wrap already
accounts for everything else above/below it but not this. */
main.page-content {
margin-bottom: 0 !important;
}
/* Gin's default 48px left/right margin on .layout-container -- fine for
a normal admin content page with a narrower reading measure, but this
app's table wants the width. Cut to a third (16px). */
.layout-container {
margin-left: 16px !important;
margin-right: 16px !important;
}
@@ -13,7 +13,11 @@
--figli-col-hover: rgba(15, 23, 42, 0.05);
font-family: Inter, -apple-system, sans-serif;
margin: 1rem 0;
/* Bottom cut to a third (top left alone) -- with the outer page
scroll already eliminated (see hide-admin-chrome.css's main.page-
content override), the full 1rem below the table was just dead
space past the last visible row. */
margin: 1rem 0 0.33rem;
color: var(--figli-text);
}
@@ -172,7 +176,15 @@ html.gin--dark-mode #figli-home-app {
overflow-x: hidden;
border: 1px solid var(--figli-border);
border-radius: 6px;
max-height: 75vh;
/* Not a flat 75vh -- title/nav + toolbar (with its filter row) above
this add up to a roughly fixed ~16rem regardless of viewport
height, so a percentage-of-viewport max-height only fits by
coincidence at one particular window size and silently reintroduces
the outer page scroll (on top of this element's own) at any other,
as toolbar filters were added over time and pushed that fixed part
past what 75vh left room for. Subtracting the fixed part directly
keeps this sized to exactly the remaining space instead. */
max-height: calc(100vh - 16rem);
overflow-y: auto;
}
@@ -292,12 +304,21 @@ html.gin--dark-mode #figli-home-app {
font-weight: 700;
}
/* Écart is called out by the Écart column itself (bold red, see
.figli-ecart below) -- no separate row-level treatment needed. */
/* Écart is called out by the Écart column itself (bold, red or green
depending on sign -- see below) -- no separate row-level treatment
needed. Répartition > Montant HT (positive écart) means the accounts
were credited more than what's actually owed for this line, the same
"more money than expected" sense .figli-montant-positif already
marks green elsewhere; the reverse (negative) stays the usual
red/error color. */
#figli-home-app td.figli-ecart {
color: var(--figli-error);
font-weight: 700;
}
#figli-home-app td.figli-ecart-positif {
color: var(--figli-positive);
font-weight: 700;
}
/* Signalé rows: a thick amber accent bar on the left edge. */
#figli-home-app tr.figli-flag-row td:first-child {
@@ -457,9 +478,26 @@ html.gin--dark-mode #figli-home-app {
#figli-home-app .amount.figli-ttc-col {
width: 5%;
}
/* Cotisation diffuseur URSSAF (1,1%) -- blank for every non-"Entrée
client" row (see figli_compta_ledger_node_presave()), same width
budget as HT/TTC since it's a real € amount, not a short rate like
TVA. */
#figli-home-app .amount.figli-cotisation-col {
width: 5%;
}
#figli-home-app .amount.figli-tva-col {
width: 3.5%;
}
/* Non-official TVA rate marker -- a thick red underline on the cell
itself rather than a small badge/dot (tried first, not visible
enough): the column is only ~3.5% wide, barely enough for
"12,64 %" itself, so a border under the whole cell reads clearly at
a glance without needing extra horizontal room a text/icon badge
would need. Tooltip (title attribute in the twig template) carries
the actual explanation. */
#figli-home-app td.figli-tva-nonofficial {
border-bottom: 3px solid var(--figli-error);
}
#figli-home-app .amount.compte-col {
width: 4.3%;
white-space: nowrap;
@@ -25,7 +25,7 @@
--flform-danger: #b3261e;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem 1rem;
max-width: 44rem;
margin: 0 auto;
@@ -59,15 +59,18 @@ html.gin--dark-mode .figli-ledger-form {
display: none;
}
/* Row layout, fully explicit (not left to grid auto-placement) --
there are now two separate 1-col-plus-2-col row groupings (Client +
N° Facture, then Montant HT + TVA + Montant TTC) and relying on DOM
order alone to keep them from bleeding into each other got fragile
once a 4th financial field (TVA) entered the picture. This must stay
in sync with the field weights set in
figli_compta_ledger_update_8004()/_figli_compta_ledger_create_node_type_ligne_comptable(). */
/* Row layout, fully explicit (not left to grid auto-placement) -- with
Cotisation now in the mix there are three separate row groupings
(Client + N° Facture, the checkbox on its own full-width row, then
Montant HT + Cotisation + TVA + Montant TTC as a clean four-column
row) and relying on DOM order alone to keep them from bleeding into
each other got fragile once that many financial fields entered the
picture. This must stay in sync with the field weights set in
figli_compta_ledger_update_8006()/_figli_compta_ledger_create_node_type_ligne_comptable(). */
.figli-ledger-form > .field--name-title,
.figli-ledger-form > .field--name-field-entree-liee,
.figli-ledger-form > .field--name-field-cotisation-active,
.figli-ledger-form > .field--name-field-tva,
.figli-ledger-form > .field--name-field-repartition,
.figli-ledger-form > .field--name-field-notes,
.figli-ledger-form > .field--name-field-flag {
@@ -77,31 +80,32 @@ html.gin--dark-mode .figli-ledger-form {
grid-column: 1 / 2;
}
.figli-ledger-form > .field--name-field-type-ligne {
grid-column: 2 / 4;
grid-column: 2 / 5;
}
.figli-ledger-form > .field--name-field-client {
grid-column: 1 / 3;
grid-column: 1 / 4;
}
.figli-ledger-form > .field--name-field-numero-facture {
grid-column: 3 / 4;
grid-column: 4 / 5;
}
.figli-ledger-form > .field--name-field-montant-ht {
grid-column: 1 / 2;
}
/* The field_tva_rate select (added in figli_compta_ledger_form_alter(),
not a real field so it gets no field--name-* class) fills the middle
slot of the HT/TVA/TTC row instead -- see that function's #weight
comment for why this specific ordering (weight 6, between HT's 5 and
TTC's 7) matters, not just matching column numbers. field_tva itself
only appears -- full width, its own row -- when "Autre" is picked. */
.figli-ledger-form > .js-form-item-field-tva-rate {
.figli-ledger-form > .field--name-field-cotisation-urssaf {
grid-column: 2 / 3;
}
.figli-ledger-form > .field--name-field-tva {
grid-column: 1 / -1;
/* The field_tva_rate select (added in figli_compta_ledger_form_alter(),
not a real field so it gets no field--name-* class) fills the third
slot of the HT/Cotisation/TVA/TTC row -- see that function's #weight
comment for why this specific ordering (weight 7, between Cotisation's
6 and TTC's 8) matters, not just matching column numbers. field_tva
itself (grouped with the other always-full-width fields above) only
appears -- full width, its own row -- when "Autre" is picked. */
.figli-ledger-form > .js-form-item-field-tva-rate {
grid-column: 3 / 4;
}
.figli-ledger-form > .field--name-field-montant-ttc {
grid-column: 3 / 4;
grid-column: 4 / 5;
}
/* Field basics */
@@ -218,6 +218,13 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
// behind that choice. A user typing a plain rate like "20" is
// unaffected either way.
_figli_field('node', 'ligne_comptable', 'field_tva', 'TVA (%)', 'decimal', ['precision' => 8, 'scale' => 4, 'min' => 0]);
// Cotisation diffuseur URSSAF (1,1%), entrée client lines only -- see
// figli_compta_ledger_update_8006()'s docblock for the full
// explanation. field_cotisation_active is the editable on/off toggle
// (a devis doesn't always include it); field_cotisation_urssaf is the
// computed amount (Montant HT * 1.011), never entered by hand.
_figli_field('node', 'ligne_comptable', 'field_cotisation_active', 'Cotisation diffuseur URSSAF (1,1%)', 'boolean');
_figli_field('node', 'ligne_comptable', 'field_cotisation_urssaf', '1,1%', 'decimal', ['precision' => 12, 'scale' => 2]);
_figli_field('node', 'ligne_comptable', 'field_montant_ttc', 'Montant TTC (€)', 'decimal', ['precision' => 12, 'scale' => 2]);
_figli_field('node', 'ligne_comptable', 'field_notes', 'Notes / détail', 'string_long');
@@ -242,19 +249,30 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
->setComponent('field_type_ligne', ['type' => 'options_select', 'weight' => 1])
->setComponent('field_client', ['type' => 'entity_reference_autocomplete', 'weight' => 2])
->setComponent('field_numero_facture', ['type' => 'string_textfield', 'weight' => 3])
// Cotisation diffuseur URSSAF (1,1%) -- only "Entrée client" lines
// go through a client devis, the only place this cotisation is
// ever invoiced (see figli_compta_ledger_update_8006()'s
// docblock). Optional even there: not every entrée historically
// included it, so a checkbox rather than an automatic
// type-based assumption. Weight 4, tied with field_entree_liee --
// the two types that show each are mutually exclusive
// (entrée vs. versement/achat/hébergement/sous_traitant), so
// which one wins the tie never matters.
->setComponent('field_cotisation_active', ['type' => 'boolean_checkbox', 'weight' => 4, 'settings' => ['display_label' => TRUE]])
->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 5])
->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 7])
// field_tva sits *after* Montant TTC, not between HT and TTC --
// figli_compta_ledger_form_alter() inserts a non-field
// "field_tva_rate" select at weight 6 (a select of the official
->setComponent('field_cotisation_urssaf', ['type' => 'number', 'weight' => 6])
->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 8])
// field_tva sits *after* Montant TTC, 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 (still at its own weight, on its own
// full-width row) when "Autre" is picked there. See
// css/ledger-form.css's grid-column rules for both.
->setComponent('field_tva', ['type' => 'number', 'weight' => 8])
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 9, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']])
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 10])
->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 11])
->setComponent('field_tva', ['type' => 'number', 'weight' => 9])
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 10, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']])
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 11])
->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 12])
->save();
}
@@ -269,12 +287,14 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
->setComponent('field_type_ligne', ['type' => 'list_default', 'weight' => 1])
->setComponent('field_client', ['type' => 'entity_reference_label', 'weight' => 2])
->setComponent('field_numero_facture', ['type' => 'string', 'weight' => 3])
->setComponent('field_cotisation_active', ['type' => 'boolean', 'weight' => 4])
->setComponent('field_montant_ht', ['type' => 'number_decimal', 'weight' => 5])
->setComponent('field_montant_ttc', ['type' => 'number_decimal', 'weight' => 7])
->setComponent('field_tva', ['type' => 'number_decimal', 'weight' => 8])
->setComponent('field_repartition', ['type' => 'entity_reference_revisions_entity_view', 'weight' => 9])
->setComponent('field_notes', ['type' => 'basic_string', 'weight' => 10])
->setComponent('field_flag', ['type' => 'entity_reference_label', 'weight' => 11])
->setComponent('field_cotisation_urssaf', ['type' => 'number_decimal', 'weight' => 6])
->setComponent('field_montant_ttc', ['type' => 'number_decimal', 'weight' => 8])
->setComponent('field_tva', ['type' => 'number_decimal', 'weight' => 9])
->setComponent('field_repartition', ['type' => 'entity_reference_revisions_entity_view', 'weight' => 10])
->setComponent('field_notes', ['type' => 'basic_string', 'weight' => 11])
->setComponent('field_flag', ['type' => 'entity_reference_label', 'weight' => 12])
->save();
}
}
@@ -493,3 +513,213 @@ function figli_compta_ledger_update_8005() {
return "TVA calculée pour $filled lignes, $skipped laissées vides (pas de Montant TTC renseigné ou Montant HT nul).";
}
/**
* Adds the cotisation diffuseur URSSAF (1,1%) fields to ligne_comptable,
* and switches Montant TTC's computation for "Entrée client" lines when
* the cotisation applies: TTC = round(cotisation * (1 + TVA/100), 2)
* instead of straight from Montant HT.
*
* The SAS invoices clients a HT amount, then -- when the devis includes
* it, not always -- adds a 1,1% "cotisation diffuseur" the SAS owes
* URSSAF for using freelancers, *then* applies TVA on top of that
* increased amount, not on the raw HT. Only "Entrée client" lines ever
* go through a client devis at all; every other type (versement, achat,
* charge...) is an outgoing/internal line with nothing to invoice a
* cotisation on, so both fields below are #states-hidden outside that
* type (see figli_compta_ledger_form_alter()) and
* figli_compta_ledger_node_presave() never applies the cascade to them.
*
* field_tva as backfilled by figli_compta_ledger_update_8005() didn't
* know about this cascade and is wrong for every entrée line that did
* have the cotisation applied (it computed straight from HT, so what it
* stored is actually the *blended* HT->TTC rate, not the real TVA
* rate) -- corrected separately in figli_compta_ledger_update_8007(),
* which also backfills field_cotisation_active/field_cotisation_urssaf
* for existing content.
*/
function figli_compta_ledger_update_8006() {
_figli_field('node', 'ligne_comptable', 'field_cotisation_active', 'Cotisation diffuseur URSSAF (1,1%)', 'boolean');
_figli_field('node', 'ligne_comptable', 'field_cotisation_urssaf', '1,1%', 'decimal', ['precision' => 12, 'scale' => 2]);
// New entrée lines default to the cotisation being applied -- the
// associates' normal practice going forward, even though it was only
// roughly half of existing content (figli_compta_ledger_update_8007()
// detects each historical line on its own rather than assuming either
// way for what's already there).
$field_active = FieldConfig::loadByName('node', 'ligne_comptable', 'field_cotisation_active');
if ($field_active && $field_active->getDefaultValueLiteral() === []) {
$field_active->setDefaultValue(1)->save();
}
// See figli_compta_ledger_update_8004()'s comment on why every weight
// here is a plain integer (fractional weights silently truncate).
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
if ($form_display) {
$form_display->setComponent('field_cotisation_active', ['type' => 'boolean_checkbox', 'weight' => 4, 'settings' => ['display_label' => TRUE]]);
$form_display->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 5] + (array) $form_display->getComponent('field_montant_ht'));
$form_display->setComponent('field_cotisation_urssaf', ['type' => 'number', 'weight' => 6]);
// Weight 7 (between Cotisation and Montant TTC) is the non-field
// "field_tva_rate" select figli_compta_ledger_form_alter() adds.
$form_display->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 8] + (array) $form_display->getComponent('field_montant_ttc'));
$form_display->setComponent('field_tva', ['type' => 'number', 'weight' => 9] + (array) $form_display->getComponent('field_tva'));
$form_display->setComponent('field_repartition', ['weight' => 10] + (array) $form_display->getComponent('field_repartition'));
$form_display->setComponent('field_notes', ['weight' => 11] + (array) $form_display->getComponent('field_notes'));
$form_display->setComponent('field_flag', ['weight' => 12] + (array) $form_display->getComponent('field_flag'));
$form_display->save();
}
$view_display = EntityViewDisplay::load('node.ligne_comptable.default');
if ($view_display) {
$view_display->setComponent('field_cotisation_active', ['type' => 'boolean', 'weight' => 4]);
$view_display->setComponent('field_cotisation_urssaf', ['type' => 'number_decimal', 'weight' => 6]);
$view_display->setComponent('field_montant_ttc', ['weight' => 8] + (array) $view_display->getComponent('field_montant_ttc'));
$view_display->setComponent('field_tva', ['weight' => 9] + (array) $view_display->getComponent('field_tva'));
$view_display->setComponent('field_repartition', ['weight' => 10] + (array) $view_display->getComponent('field_repartition'));
$view_display->setComponent('field_notes', ['weight' => 11] + (array) $view_display->getComponent('field_notes'));
$view_display->setComponent('field_flag', ['weight' => 12] + (array) $view_display->getComponent('field_flag'));
$view_display->save();
}
}
/**
* Backfills field_cotisation_active/field_cotisation_urssaf and
* corrects field_tva for existing "Entrée client" lines, now that the
* HT -> +1,1% -> +TVA cascade explained in
* figli_compta_ledger_update_8006()'s docblock is understood. 2021 is
* left untouched entirely (explicit instruction -- that year's
* cotisation practice isn't confirmed), and Montant TTC itself is never
* written here, same "never correct historical data" rule as every
* other migration in this module.
*
* Per entrée line (2022+, both Montant HT and Montant TTC present):
* - old_rate = (TTC/HT - 1) * 100 -- what
* figli_compta_ledger_update_8005() already stored in field_tva.
* - If old_rate already lands on an official French VAT rate (0, 2.1,
* 5.5, 10, 20 -- ±0.05 points, allowing for rounding through 2-decimal
* HT/TTC storage), the cotisation was never applied to this line:
* field_tva is already correct as migrated, left alone, cotisation
* marked inactive.
* - Otherwise, new_rate = (TTC / (HT * 1.011) - 1) * 100. If *that*
* lands on an official rate, the cotisation was applied and old_rate
* was the wrong (blended HT->TTC) figure all along -- field_tva
* corrected to new_rate, cotisation marked active, field_cotisation_
* urssaf backfilled to HT * 1.011.
* - If neither lands cleanly, this is an ambiguous/blended case (a
* handful of genuinely multi-rate invoices collapsed into one line,
* same category figli_compta_ledger_update_8005() already found at 4-
* decimal precision) -- field_tva left untouched, cotisation marked
* inactive, rather than guess.
*
* Checked empirically before writing this (287 entrée lines with both
* amounts, 2022+): 90 already official (left alone), 154 corrected, 40
* still ambiguous both ways (left alone), 3 missing HT or TTC.
*/
function figli_compta_ledger_update_8007() {
$official = [0.0, 2.1, 5.5, 10.0, 20.0];
$epsilon = 0.05;
$matches_official = function ($rate) use ($official, $epsilon) {
foreach ($official as $o) {
if (abs($rate - $o) < $epsilon) {
return TRUE;
}
}
return FALSE;
};
$storage = \Drupal::entityTypeManager()->getStorage('node');
$nids = $storage->getQuery()
->accessCheck(FALSE)
->condition('type', 'ligne_comptable')
->condition('field_type_ligne', 'entree')
->execute();
$skipped2021 = 0;
$noData = 0;
$alreadyOfficial = 0;
$corrected = 0;
$stillAmbiguous = 0;
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
foreach ($storage->loadMultiple($nids) as $node) {
$date = $node->hasField('field_date_ligne') ? $node->get('field_date_ligne')->value : NULL;
if ($date && substr($date, 0, 4) === '2021') {
$skipped2021++;
continue;
}
$ht = $node->hasField('field_montant_ht') && !$node->get('field_montant_ht')->isEmpty()
? (float) $node->get('field_montant_ht')->value : NULL;
$ttc = $node->hasField('field_montant_ttc') && !$node->get('field_montant_ttc')->isEmpty()
? (float) $node->get('field_montant_ttc')->value : NULL;
if ($ht === NULL || $ttc === NULL || abs($ht) < 0.0001) {
$node->set('field_cotisation_active', FALSE);
$node->save();
$noData++;
continue;
}
$old_rate = round(($ttc / $ht - 1) * 100, 4);
if ($matches_official($old_rate)) {
$node->set('field_cotisation_active', FALSE);
$node->save();
$alreadyOfficial++;
continue;
}
$new_rate = round(($ttc / ($ht * 1.011) - 1) * 100, 4);
if (!$matches_official($new_rate)) {
$node->set('field_cotisation_active', FALSE);
$node->save();
$stillAmbiguous++;
continue;
}
$node->set('field_cotisation_active', TRUE);
$node->set('field_cotisation_urssaf', round($ht * 1.011, 2));
$node->set('field_tva', $new_rate);
$node->save();
$corrected++;
}
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
return "Cotisation/TVA : $corrected lignes corrigées (cotisation active), $alreadyOfficial déjà correctes (cotisation inactive), $stillAmbiguous laissées telles quelles (taux non standard des deux façons), $noData sans HT/TTC, $skipped2021 lignes 2021 ignorées.";
}
/**
* Corrects what field_cotisation_urssaf stores for the lines
* figli_compta_ledger_update_8007() marked cotisation-active: that
* migration (and figli_compta_ledger_node_presave() as first written)
* stored Montant HT + 1,1% there, but "1,1%" as a field/column label
* means just the 1,1% itself -- the delta, not the augmented total.
* TVA still applies to the augmented base (Montant HT + this field), so
* Montant TTC is untouched by this correction; only
* field_cotisation_urssaf's own value changes, from round(HT * 1.011, 2)
* to round(HT * 0.011, 2). Safe to run unconditionally on every
* cotisation-active line -- this is a bug in code from earlier the same
* day, not a judgment call about ambiguous historical data like
* figli_compta_ledger_update_8007()'s.
*/
function figli_compta_ledger_update_8008() {
$storage = \Drupal::entityTypeManager()->getStorage('node');
$nids = $storage->getQuery()
->accessCheck(FALSE)
->condition('type', 'ligne_comptable')
->condition('field_cotisation_active', 1)
->execute();
$fixed = 0;
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
foreach ($storage->loadMultiple($nids) as $node) {
if (!$node->hasField('field_montant_ht') || $node->get('field_montant_ht')->isEmpty()) {
continue;
}
$ht = (float) $node->get('field_montant_ht')->value;
$node->set('field_cotisation_urssaf', round($ht * 0.011, 2));
$node->save();
$fixed++;
}
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
return "field_cotisation_urssaf corrigé (delta seul, pas HT + delta) pour $fixed lignes.";
}
@@ -119,6 +119,27 @@ 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.');
}
// Cotisation diffuseur URSSAF (1,1%) -- only ever relevant for
// "Entrée client" lines (the only type invoiced to a client via a
// devis; see figli_compta_ledger_update_8006()'s docblock), so both
// fields are hidden otherwise. Real #states on real fields watching
// the real field_type_ligne select -- unlike field_tva_rate below,
// this is the same working pattern already used a few fields up on
// field_entree_liee, not the custom select that #states silently
// failed to toggle.
if (isset($form['field_cotisation_active'])) {
$form['field_cotisation_active']['#states'] = [
'visible' => [':input[name="field_type_ligne"]' => ['value' => 'entree']],
];
}
if (isset($form['field_cotisation_urssaf']['widget'][0]['value'])) {
$form['field_cotisation_urssaf']['#states'] = [
'visible' => [':input[name="field_type_ligne"]' => ['value' => 'entree']],
];
$form['field_cotisation_urssaf']['widget'][0]['value']['#disabled'] = TRUE;
$form['field_cotisation_urssaf']['widget'][0]['value']['#description'] = t('Calculé automatiquement (1,1% du montant HT) si la cotisation ci-dessus est cochée. La TVA est ensuite appliquée sur Montant HT + ce montant.');
}
// TVA: a select of the official French rates covers the overwhelming
// majority of new lines with one click, instead of associates having
// to know/type "20" or "5.5" by heart. "Autre" reveals the real
@@ -162,10 +183,10 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state,
// delta-indexed value array, and chokes on an unrelated sibling key
// mixed into it.
//
// Weight 6: the exact slot Montant HT (5)/Montant TTC (7) leave open
// between them in figli_compta_ledger_update_8004() -- field_tva
// itself has been moved to weight 8 (its own full-width row) so
// that this select forms a clean HT/rate/TTC three-column row
// Weight 7: the slot between field_cotisation_urssaf (6) and Montant
// TTC (8) left open in figli_compta_ledger_update_8006() -- field_tva
// itself sits at weight 9 (its own full-width row) so that this
// select forms a clean HT/Cotisation/rate/TTC four-column row
// whether or not "Autre" is currently revealing the real field_tva
// input further down. See css/ledger-form.css's grid-column rules
// for both -- the row layout there depends on this exact ordering,
@@ -175,7 +196,7 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state,
'#title' => t('TVA'),
'#options' => $taux_officiels + ['autre' => t('Autre (préciser)')],
'#default_value' => $rate_default,
'#weight' => 6,
'#weight' => 7,
];
$tva_field['#title'] = t('Taux exact (%)');
// Not #states: this element sits outside the field system (see the
@@ -338,30 +359,63 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
$montant_ht = (float) $node->get('field_montant_ht')->value;
$tva = $node->hasField('field_tva') && !$node->get('field_tva')->isEmpty() ? (float) $node->get('field_tva')->value : 0.0;
$type = $node->hasField('field_type_ligne') ? $node->get('field_type_ligne')->value : NULL;
$cotisation_active = $node->hasField('field_cotisation_active') && !$node->get('field_cotisation_active')->isEmpty()
? (bool) $node->get('field_cotisation_active')->value : FALSE;
// Only "Entrée client" lines ever go through a client devis -- the
// only place the cotisation diffuseur is invoiced at all (see
// figli_compta_ledger_update_8006()'s docblock) -- so the checkbox is
// ignored for every other type even if it somehow ended up checked
// (e.g. a line's type changed after the fact).
$cotisation_applies = $type === 'entree' && $cotisation_active;
// Montant TTC is no longer entered by hand -- it's a pure function of
// Montant HT and TVA (see figli_compta_ledger_update_8004()). Only
// recompute it when HT or TVA actually changed, though, rather than on
// every save unconditionally: the TVA backfilled onto pre-migration
// lines (figli_compta_ledger_update_8005()) is a best-effort
// reconstruction and doesn't reproduce every historical Montant TTC to
// the exact centime, so blindly recomputing on an unrelated edit
// (fixing a typo in Libellé, say) would silently nudge an untouched
// historical value -- exactly what this module's "never correct
// historical data" rule exists to prevent. A genuinely new/changed
// HT or TVA has no such history to protect.
// Montant HT, TVA, and (entrée client only) the cotisation diffuseur
// URSSAF cascade (see figli_compta_ledger_update_8004()/_8006()). Only
// recompute it when HT, TVA, type, or the cotisation toggle actually
// changed, though, rather than on every save unconditionally: the TVA
// backfilled onto pre-migration lines (figli_compta_ledger_update_8005()/
// _8007()) is a best-effort reconstruction and doesn't reproduce every
// historical Montant TTC to the exact centime, so blindly recomputing
// on an unrelated edit (fixing a typo in Libellé, say) would silently
// nudge an untouched historical value -- exactly what this module's
// "never correct historical data" rule exists to prevent. A genuinely
// new/changed HT, TVA, type, or cotisation toggle has no such history
// to protect.
if ($node->hasField('field_montant_ttc')) {
$ht_or_tva_changed = $node->isNew();
if (!$ht_or_tva_changed && isset($node->original)) {
$inputs_changed = $node->isNew();
if (!$inputs_changed && isset($node->original)) {
$original = $node->original;
$orig_ht = $original->hasField('field_montant_ht') && !$original->get('field_montant_ht')->isEmpty()
? (float) $original->get('field_montant_ht')->value : NULL;
$orig_tva = $original->hasField('field_tva') && !$original->get('field_tva')->isEmpty()
? (float) $original->get('field_tva')->value : 0.0;
$ht_or_tva_changed = $orig_ht !== $montant_ht || abs($orig_tva - $tva) > 0.00005;
$orig_type = $original->hasField('field_type_ligne') ? $original->get('field_type_ligne')->value : NULL;
$orig_cotisation_active = $original->hasField('field_cotisation_active') && !$original->get('field_cotisation_active')->isEmpty()
? (bool) $original->get('field_cotisation_active')->value : FALSE;
$inputs_changed = $orig_ht !== $montant_ht || abs($orig_tva - $tva) > 0.00005
|| $orig_type !== $type || $orig_cotisation_active !== $cotisation_active;
}
if ($ht_or_tva_changed) {
$node->set('field_montant_ttc', round($montant_ht * (1 + $tva / 100), 2));
if ($inputs_changed) {
if ($cotisation_applies) {
// field_cotisation_urssaf stores just the 1,1% itself (the
// delta), not Montant HT + 1,1% -- that's what "1,1%" as a
// column/field label means. TVA still applies to the augmented
// base though, so $base (used for Montant TTC just below) stays
// HT + delta; only what gets *written* to the field changes.
$delta = round($montant_ht * 0.011, 2);
$base = $montant_ht + $delta;
if ($node->hasField('field_cotisation_urssaf')) {
$node->set('field_cotisation_urssaf', $delta);
}
}
else {
$base = $montant_ht;
if ($node->hasField('field_cotisation_urssaf')) {
$node->set('field_cotisation_urssaf', NULL);
}
}
$node->set('field_montant_ttc', round($base * (1 + $tva / 100), 2));
}
}
@@ -422,6 +476,7 @@ function figli_compta_ledger_page_attachments(array &$attachments) {
$front_end_routes = [
'figli_compta_ledger.home',
'figli_compta_ledger.dashboard',
'figli_compta_ledger.dashboard_compte',
'figli_compta_ledger.history',
'figli_compta_ledger.link_entree',
];
@@ -26,6 +26,12 @@
// stores a plain rate (10), which style: 'percent' would otherwise
// silently read as 1000%.
const PCT = new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
// Mirrors the $taux_officiels select in figli_compta_ledger_form_alter()
// and the $official list in figli_compta_ledger_update_8007()/_8008() --
// same 0.05-point tolerance as those migration scripts, for the same
// "rounding through 2-decimal HT/TTC storage" reason.
const OFFICIAL_TVA_RATES = [0, 2.1, 5.5, 10, 20];
const OFFICIAL_TVA_EPSILON = 0.05;
const MONTHS = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
const TYPE_LABELS = {
entree: 'Entrée client',
@@ -369,6 +375,7 @@
facture: attrs.field_numero_facture || null,
libelle: attrs.field_notes || attrs.title,
montant_ht: montantHt,
cotisation: attrs.field_cotisation_urssaf !== null && attrs.field_cotisation_urssaf !== undefined ? parseFloat(attrs.field_cotisation_urssaf) : null,
tva: attrs.field_tva !== null && attrs.field_tva !== undefined ? parseFloat(attrs.field_tva) : null,
montant_ttc: attrs.field_montant_ttc !== null && attrs.field_montant_ttc !== undefined ? parseFloat(attrs.field_montant_ttc) : null,
parCompte,
@@ -680,6 +687,16 @@
formatPct(v) {
return v === null || v === undefined ? '' : PCT.format(v) + ' %';
},
// Flags a TVA rate that isn't one of the 5 official French VAT
// rates -- either a genuinely blended/multi-rate invoice collapsed
// into one ligne_comptable, or a migrated line
// figli_compta_ledger_update_8007() left ambiguous rather than
// guess (see that function's docblock). Null (no TVA recorded at
// all) is never flagged -- nothing to question there.
isTvaOfficial(v) {
if (v === null || v === undefined) return true;
return OFFICIAL_TVA_RATES.some((o) => Math.abs(v - o) < OFFICIAL_TVA_EPSILON);
},
// Écart between this ouverture row's year and the previous year's
// calculated closing balance -- restricted to whichever compte(s)
// this specific row's répartition actually touches (ouverture lines
@@ -27,6 +27,9 @@
var tva = form.querySelector('[name="field_tva[0][value]"]');
var rate = form.querySelector('[name="field_tva_rate"]');
var ttc = form.querySelector('[name="field_montant_ttc[0][value]"]');
var type = form.querySelector('[name="field_type_ligne"]');
var cotisationActive = form.querySelector('[name="field_cotisation_active[value]"]');
var cotisationAmount = form.querySelector('[name="field_cotisation_urssaf[0][value]"]');
if (!tva || !ttc) {
return;
}
@@ -42,6 +45,13 @@
return parseFloat(tva.value);
}
// Mirrors figli_compta_ledger_node_presave()'s $cotisation_applies:
// only "Entrée client" lines with the checkbox on ever go through
// the +1,1% step before TVA.
function cotisationApplies() {
return !!type && type.value === 'entree' && !!cotisationActive && cotisationActive.checked;
}
function recompute() {
var htValue = parseFloat(ht.value);
if (isNaN(htValue)) {
@@ -51,7 +61,22 @@
if (isNaN(tvaValue)) {
tvaValue = 0;
}
ttc.value = (htValue * (1 + tvaValue / 100)).toFixed(2);
var base = htValue;
if (cotisationApplies()) {
// Displayed/stored value is just the 1,1% itself (the
// delta), not HT + 1,1% -- but TVA still applies to the
// augmented base, so $base for the TTC calc below stays
// htValue + delta regardless.
var delta = Math.round(htValue * 0.011 * 100) / 100;
base = htValue + delta;
if (cotisationAmount) {
cotisationAmount.value = delta.toFixed(2);
}
}
else if (cotisationAmount) {
cotisationAmount.value = '';
}
ttc.value = (base * (1 + tvaValue / 100)).toFixed(2);
}
// Deliberately not called once up front on an existing (edit)
@@ -60,17 +85,23 @@
// the centime (see figli_compta_ledger_update_8005()'s
// docblock) -- showing a recomputed number the instant the
// modal opens, before the associate has touched anything, would
// misrepresent what's actually saved. Only recompute once HT,
// TVA, or the rate select are actually edited, matching the
// same "only overwrite Montant TTC when HT/TVA actually
// changed" guard in figli_compta_ledger_node_presave(). Harmless
// no-op on a genuinely new/blank line either way (HT is empty,
// recompute() bails out above).
// misrepresent what's actually saved. Only recompute once an
// actual input happens, matching the same "only overwrite
// Montant TTC when an input actually changed" guard in
// figli_compta_ledger_node_presave(). Harmless no-op on a
// genuinely new/blank line either way (HT is empty, recompute()
// bails out above).
ht.addEventListener('input', recompute);
tva.addEventListener('input', recompute);
if (rate) {
rate.addEventListener('change', recompute);
}
if (type) {
type.addEventListener('change', recompute);
}
if (cotisationActive) {
cotisationActive.addEventListener('change', recompute);
}
});
}
};
@@ -57,6 +57,34 @@ class HistoryController extends ControllerBase {
}
return [
// Same markup/classes as the <nav class="figli-page-nav"> block in
// figli-compta-home.html.twig et al. (styled in admin-chrome.css,
// pinned next to the page title) -- this controller has no twig
// template of its own to put a real <nav> in, but the CSS only
// ever targets the class, not the tag, so a render-array
// 'container' (<div>) here looks identical. None of the three
// links is ever "active" here since this history feed isn't one
// of them -- same as visiting it from any of the other pages'
// nav, which doesn't include a 4th "Historique" entry either.
'nav' => [
'#type' => 'container',
'#attributes' => ['class' => ['figli-page-nav']],
'home' => [
'#type' => 'link',
'#title' => $this->t('Grand livre'),
'#url' => Url::fromRoute('figli_compta_ledger.home'),
],
'dashboard' => [
'#type' => 'link',
'#title' => $this->t('Dashboard'),
'#url' => Url::fromRoute('figli_compta_ledger.dashboard'),
],
'dashboard_compte' => [
'#type' => 'link',
'#title' => $this->t('Par compte'),
'#url' => Url::fromRoute('figli_compta_ledger.dashboard_compte'),
],
],
'table' => [
'#type' => 'table',
'#header' => [
@@ -43,6 +43,7 @@ class LedgerStatsController extends ControllerBase {
$par_compte = [];
$montant_ht = 0.0;
$cotisation = 0.0;
$montant_ttc = 0.0;
$ecart = 0.0;
foreach ($storage->loadMultiple($nids) as $node) {
@@ -52,6 +53,13 @@ class LedgerStatsController extends ControllerBase {
? (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) {
@@ -71,6 +79,7 @@ class LedgerStatsController extends ControllerBase {
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),
@@ -129,6 +129,7 @@
<th>Libellé / Détail</th>
<th>Signalement</th>
<th class="amount figli-ht-col">HT</th>
<th class="amount figli-cotisation-col">1,1%</th>
<th class="amount figli-tva-col">TVA</th>
<th class="amount figli-ttc-col">TTC</th>
<th v-for="c in allComptes" :key="c" class="amount compte-col">{{ c }}</th>
@@ -137,13 +138,13 @@
</thead>
<tbody>
<tr ref="topSentinel" class="figli-sentinel-row">
<td :colspan="7 + allComptes.length + 4">
<td :colspan="7 + allComptes.length + 5">
<span v-if="loadingOlder">Chargement des mois précédents…</span>
</td>
</tr>
<template v-for="item in groupedRows" :key="item.key">
<tr v-if="item.isGroup" class="figli-group-row" :data-year="item.year">
<td :colspan="7 + allComptes.length + 4">{{ item.label }} <span class="figli-note">({{ item.count }} lignes)</span></td>
<td :colspan="7 + allComptes.length + 5">{{ item.label }} <span class="figli-note">({{ item.count }} lignes)</span></td>
</tr>
<tr v-else :class="{'figli-flag-row': item.hasFlag}" :data-year="item.date ? item.date.slice(0, 4) : null">
<td class="actions-col">
@@ -241,14 +242,15 @@
</span>
</td>
<td class="amount figli-ht-col" :class="montantClass(item.montant_ht)">{{ formatEur(item.montant_ht) }}</td>
<td class="amount figli-tva-col">{{ formatPct(item.tva) }}</td>
<td class="amount figli-cotisation-col">{{ formatEur(item.cotisation) }}</td>
<td class="amount figli-tva-col" :class="{'figli-tva-nonofficial': !isTvaOfficial(item.tva)}" :title="!isTvaOfficial(item.tva) ? 'Taux TVA non officiel (ni 0, 2,1, 5,5, 10 ni 20 %)' : null">{{ formatPct(item.tva) }}</td>
<td class="amount figli-ttc-col">{{ formatEur(item.montant_ttc) }}</td>
<td v-for="c in allComptes" :key="c" class="amount compte-col">{{ item.parCompte[c] !== undefined ? formatEur(item.parCompte[c]) : '' }}</td>
<td class="amount" :class="{'figli-ecart': item.hasError}">{{ item.hasError ? formatEur(item.ecart) : '' }}</td>
<td class="amount" :class="{'figli-ecart': item.hasError && item.ecart < 0, 'figli-ecart-positif': item.hasError && item.ecart > 0}">{{ item.hasError ? formatEur(item.ecart) : '' }}</td>
</tr>
</template>
<tr ref="bottomSentinel" class="figli-sentinel-row">
<td :colspan="7 + allComptes.length + 4">
<td :colspan="7 + allComptes.length + 5">
<span v-if="loadingNewer">Chargement des mois suivants…</span>
</td>
</tr>
@@ -261,6 +263,7 @@
<span v-if="currentYearLoading" class="figli-note">chargement…</span>
</td>
<td class="amount figli-ht-col">{{ currentYearTotals ? formatEur(currentYearTotals.montant_ht) : '' }}</td>
<td class="amount figli-cotisation-col">{{ currentYearTotals ? formatEur(currentYearTotals.cotisation) : '' }}</td>
<td class="amount figli-tva-col"></td>
<td class="amount figli-ttc-col">{{ currentYearTotals ? formatEur(currentYearTotals.montant_ttc) : '' }}</td>
<td v-for="c in allComptes" :key="c" class="amount compte-col" :class="currentYearTotals ? soldeClass(currentYearTotals.par_compte[c]) : ''">{{ currentYearTotals && currentYearTotals.par_compte[c] !== undefined ? formatEur(currentYearTotals.par_compte[c]) : '' }}</td>
@@ -321,7 +324,7 @@
<td class="amount" :class="montantClass(item.montant_ht)">{{ formatEur(item.montant_ht) }}</td>
<td class="amount">{{ formatEur(item.montant_ttc) }}</td>
<td v-for="c in modalComptes" :key="c" class="amount compte-col">{{ item.parCompte[c] !== undefined ? formatEur(item.parCompte[c]) : '' }}</td>
<td class="amount" :class="{'figli-ecart': item.hasError}">{{ item.hasError ? formatEur(item.ecart) : '' }}</td>
<td class="amount" :class="{'figli-ecart': item.hasError && item.ecart < 0, 'figli-ecart-positif': item.hasError && item.ecart > 0}">{{ item.hasError ? formatEur(item.ecart) : '' }}</td>
</tr>
</tbody>
<tfoot>