Compare commits

...
5 Commits
Author SHA1 Message Date
bachir f0927bac88 TVA par défaut à 10 %, corrige le Montant TTC illisible
- TVA par défaut passe de 0 % à 10 % (taux intermédiaire) pour une
  nouvelle ligne -- la plupart des lignes saisies portent effectivement
  de la TVA.
- Montant TTC (désactivé) était illisible : Chromium affiche le texte
  d'un champ disabled via -webkit-text-fill-color plutôt que color,
  qui restait sur son gris par défaut du navigateur -- quasi invisible
  sur le fond gris de --flform-bg-subtle en mode sombre. Fixé
  explicitement sur les deux thèmes.
2026-09-07 12:01:33 +02:00
bachir 5aa00b2ffd TVA en sélection des taux officiels français, TTC non éditable
Le champ TVA devient un select (0 %, 2,1 %, 5,5 %, 10 %, 20 %) plutôt
qu'une saisie libre, avec une option "Autre (préciser)" qui révèle le
champ décimal existant -- indispensable pour les lignes migrées, dont
le taux rétro-calculé (figli_compta_ledger_update_8005()) est souvent
un taux effectif non standard qu'il ne faut surtout pas forcer à
s'aligner sur l'une des 5 valeurs officielles.

Montant TTC passe de readonly à #disabled : non focusable, non
éditable même via les flèches d'un input number, et Form API rejette
toute valeur soumise malgré tout au profit de #default_value.

Le select est un élément de formulaire à part (pas un widget de champ)
pour éviter que WidgetBase::extractFormValues() ne s'étouffe sur une
clé étrangère mêlée aux valeurs de field_tva -- un #validate callback
recopie la valeur choisie dans field_tva au moment opportun (après
tous les #validate, avant la reconstruction de l'entité au submit).

L'affichage/masquage du champ "Autre" repose sur du JS simple plutôt
que sur #states : #states pose bien l'attribut data-drupal-states mais
ne bascule jamais la visibilité dans cette modale AJAX précise, pour
une raison non identifiée (un #states pourtant fonctionnel existe
juste au-dessus, sur field_entree_liee, qui surveille un vrai champ
Field API plutôt qu'un select ajouté à la main).
2026-09-07 11:56:08 +02:00
bachir b7490eb76a Ajoute la TVA (%) et calcule automatiquement le Montant TTC
Architecture : Montant HT reste saisi à la main, un nouveau champ TVA
(%) le complète, et Montant TTC = round(HT * (1 + TVA/100), 2) devient
une valeur calculée plutôt que saisie -- champ readonly dans le
formulaire (aperçu live en JS), calcul faisant foi côté serveur dans
figli_compta_ledger_node_presave().

Migration (update hooks 8004/8005) : Montant TTC n'est **jamais**
modifié pour les données historiques -- seul un TVA effectif est
rétro-calculé depuis HT/TTC existants et ajouté en tant que nouvelle
métadonnée (1498 lignes renseignées, 58 laissées vides faute de TTC
source). Précision du champ TVA fixée à 4 décimales après vérification
empirique sur les 1556 lignes existantes : reconstruire TTC = HT * (1 +
TVA/100) avec un taux arrondi à 4 décimales ne s'écarte du TTC réel que
pour 9 lignes (probables factures multi-taux), contre 74 à 2 décimales.

Garde-fou supplémentaire dans le presave : le recalcul du TTC ne se
déclenche que si HT ou TVA ont réellement changé par rapport à la
révision précédente (comparaison à $node->original) -- sans ça,
rouvrir une ancienne ligne migrée pour corriger un simple libellé
aurait silencieusement dérivé son TTC historique de ±0,01€ à cause de
l'arrondi du taux rétro-calculé, ce qu'interdit la règle du projet de
ne jamais corriger les données historiques.

Réorganisation du formulaire : N° Facture rejoint Client sur une même
ligne, Montant HT/TVA/Montant TTC forment la ligne suivante -- les
poids de champs doivent rester des entiers (Drupal tronque silencieusement
tout poids fractionnaire lors de la sauvegarde de l'affichage).
2026-09-07 11:36:53 +02:00
bachir f00f680143 Corrige trois régressions du restyling du formulaire de ligne comptable
- Le select "Type de ligne" affichait un chevron géant répété (le
  shorthand `background: transparent` sur input/select/textarea
  réinitialisait aussi position/repeat/size de Claro, transformant sa
  flèche unique alignée à droite en motif carrelé). Les select gardent
  maintenant leur background Claro intact.
- Les menus "Toggle Actions" (⋮) de la Répartition étaient ouverts en
  permanence : mon display:flex écrasait le display:none par défaut de
  Claro, cassant le toggle piloté par paragraphs.actions.js. Rescopé à
  `.paragraphs-dropdown.open .paragraphs-dropdown-actions`.
- Marges resserrées : le `.form-item { margin-block: 1.5rem }` de Claro
  s'ajoutait à notre propre grid-gap. Neutralisé (`margin-block: 0`), et
  gap/paddings réduits.
2026-09-07 11:14:24 +02:00
bachir 4ce1b12099 Modernise l'UI du formulaire modal d'ajout/édition de ligne comptable
Le formulaire natif Drupal (Menu settings, URL alias, Authoring
information, tabledrag/drag-handle pour des tableaux de 1-3 lignes,
"Title" affiché tel quel) était pensé pour un éditeur de contenu
générique, pas pour la saisie numérique quotidienne d'une ligne
comptable. Sans toucher au Form API (validation, structure des champs
inchangées) :

- Grille compacte à 3 colonnes (Date+Type, Client, Montant HT/Facture/
  TTC en ligne), au lieu de l'empilement vertical par défaut.
- Menu settings / URL alias / Authoring information / Published
  masqués via #access -- jamais utilisés pour une ligne_comptable.
- "Title" relabellisé en "Libellé court" avec une description qui
  explicite son rôle de repli quand "Notes / détail" est vide (déjà le
  comportement de home.js/dashboard*.js, jusque-là invisible côté
  formulaire).
- Répartition / Entrée liée : poignée de glisser-déposer et bascule
  "Show row weights" masquées (l'ordre n'affecte jamais la somme ni
  l'affichage), boutons Ajouter/Retirer/Dupliquer restylés.

Classe CSS partagée `figli-ledger-form` ajoutée par form_alter plutôt
que de cibler la classe générée par Drupal, différente entre le
formulaire d'ajout (node-ligne-comptable-form) et celui d'édition
(node-ligne-comptable-edit-form).
2026-09-07 09:57:37 +02:00
9 changed files with 808 additions and 24 deletions
@@ -12,6 +12,7 @@ dependencies:
- field.field.node.ligne_comptable.field_notes
- field.field.node.ligne_comptable.field_numero_facture
- field.field.node.ligne_comptable.field_repartition
- field.field.node.ligne_comptable.field_tva
- field.field.node.ligne_comptable.field_type_ligne
- node.type.ligne_comptable
module:
@@ -47,7 +48,7 @@ content:
third_party_settings: { }
field_entree_liee:
type: entity_reference_autocomplete
weight: 2
weight: 4
region: content
settings:
match_operator: CONTAINS
@@ -57,7 +58,7 @@ content:
third_party_settings: { }
field_flag:
type: entity_reference_autocomplete_tags
weight: 8
weight: 11
region: content
settings:
match_operator: CONTAINS
@@ -67,21 +68,21 @@ content:
third_party_settings: { }
field_montant_ht:
type: number
weight: 3
weight: 5
region: content
settings:
placeholder: ''
third_party_settings: { }
field_montant_ttc:
type: number
weight: 4
weight: 7
region: content
settings:
placeholder: ''
third_party_settings: { }
field_notes:
type: string_textarea
weight: 6
weight: 10
region: content
settings:
rows: 5
@@ -97,7 +98,7 @@ content:
third_party_settings: { }
field_repartition:
type: paragraphs
weight: 5
weight: 9
region: content
settings:
title: Répartition
@@ -113,6 +114,13 @@ content:
collapse_edit_all: collapse_edit_all
duplicate: duplicate
third_party_settings: { }
field_tva:
type: number
weight: 8
region: content
settings:
placeholder: ''
third_party_settings: { }
field_type_ligne:
type: options_select
weight: 1
@@ -12,6 +12,7 @@ dependencies:
- field.field.node.ligne_comptable.field_notes
- field.field.node.ligne_comptable.field_numero_facture
- field.field.node.ligne_comptable.field_repartition
- field.field.node.ligne_comptable.field_tva
- field.field.node.ligne_comptable.field_type_ligne
- node.type.ligne_comptable
module:
@@ -55,7 +56,7 @@ content:
settings:
link: true
third_party_settings: { }
weight: 8
weight: 11
region: content
field_montant_ht:
type: number_decimal
@@ -66,7 +67,7 @@ content:
scale: 2
prefix_suffix: true
third_party_settings: { }
weight: 3
weight: 5
region: content
field_montant_ttc:
type: number_decimal
@@ -77,14 +78,14 @@ content:
scale: 2
prefix_suffix: true
third_party_settings: { }
weight: 4
weight: 7
region: content
field_notes:
type: basic_string
label: above
settings: { }
third_party_settings: { }
weight: 6
weight: 10
region: content
field_numero_facture:
type: string
@@ -102,7 +103,18 @@ content:
view_mode: default
link: ''
third_party_settings: { }
weight: 5
weight: 9
region: content
field_tva:
type: number_decimal
label: above
settings:
thousand_separator: ''
decimal_separator: .
scale: 2
prefix_suffix: true
third_party_settings: { }
weight: 8
region: content
field_type_ligne:
type: list_default
@@ -0,0 +1,25 @@
uuid: 9e9fc15c-9f7b-466a-bab0-fc6fe802a22f
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_tva
- node.type.ligne_comptable
id: node.ligne_comptable.field_tva
field_name: field_tva
entity_type: node
bundle: ligne_comptable
label: 'TVA (%)'
description: ''
required: false
translatable: true
default_value:
-
value: 10.0
default_value_callback: ''
settings:
min: null
max: null
prefix: ''
suffix: ''
field_type: decimal
@@ -0,0 +1,20 @@
uuid: 92ad1651-ab19-4c60-ad8c-4824fd97ecc3
langcode: en
status: true
dependencies:
module:
- node
id: node.field_tva
field_name: field_tva
entity_type: node
type: decimal
settings:
precision: 8
scale: 4
module: core
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false
@@ -0,0 +1,284 @@
/* Ligne comptable add/edit form (node/add/ligne_comptable, node/X/edit --
usually seen inside the "+ Ajouter une ligne" / "Modifier" AJAX modal
from /lignes, see js/home.js's openAddForm/openEditForm). The default
Drupal/Gin admin form widgets (tabledrag drag handles, the paragraphs
"Toggle Actions" dropdown, vertical tabs rendered as an accordion) are
built for content editors managing arbitrary nodes, not for the small
numeric entry form associates fill in daily. This only restyles it
into a compact grid -- no Form API behavior, validation, or field
structure changes here (those live in figli_compta_ledger.module,
which also hides the "advanced" accordion and "Published" checkbox
entirely rather than just visually, since neither is ever used for a
ligne_comptable).
Explicit local colors rather than Gin's --gin-* custom properties --
this form isn't scoped inside #figli-home-app/#figli-dashboard-app, so
the same "don't trust Gin's vars on custom routes" lesson from
home.css/dashboard.css applies here too. */
.figli-ledger-form {
--flform-border: #dcdee2;
--flform-border-soft: #e8eaed;
--flform-label: #4b5563;
--flform-text: #1a1a1a;
--flform-bg-subtle: #f7f8fa;
--flform-accent: #2f6f4f;
--flform-danger: #b3261e;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem 1rem;
max-width: 44rem;
margin: 0 auto;
padding: 0.15rem 0.1rem 0.5rem;
color: var(--flform-text);
}
/* Claro gives every .form-item a large margin-block (1.5rem, meant for a
form with one field per row) -- with our own grid `gap` now doing that
job, this just doubles up the spacing between rows. */
.figli-ledger-form .form-item {
margin-block: 0;
}
html.gin--dark-mode .figli-ledger-form {
--flform-border: #3d3e42;
--flform-border-soft: #333438;
--flform-label: #a1a5ab;
--flform-text: #e8e9ea;
--flform-bg-subtle: #2a2b2e;
--flform-accent: #5fb98a;
--flform-danger: #ff6b6b;
}
/* Wrapper divs Drupal still renders even though they're always empty
here (single-language site, path aliases handled elsewhere, status
hidden via #access in the module) -- without this they'd each still
claim an empty grid cell. */
.figli-ledger-form > .field--name-langcode,
.figli-ledger-form > .field--name-path,
.figli-ledger-form > .entity-content-form-footer,
.figli-ledger-form > .form-actions {
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(). */
.figli-ledger-form > .field--name-title,
.figli-ledger-form > .field--name-field-entree-liee,
.figli-ledger-form > .field--name-field-repartition,
.figli-ledger-form > .field--name-field-notes,
.figli-ledger-form > .field--name-field-flag {
grid-column: 1 / -1;
}
.figli-ledger-form > .field--name-field-date-ligne {
grid-column: 1 / 2;
}
.figli-ledger-form > .field--name-field-type-ligne {
grid-column: 2 / 4;
}
.figli-ledger-form > .field--name-field-client {
grid-column: 1 / 3;
}
.figli-ledger-form > .field--name-field-numero-facture {
grid-column: 3 / 4;
}
.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 {
grid-column: 2 / 3;
}
.figli-ledger-form > .field--name-field-tva {
grid-column: 1 / -1;
}
.figli-ledger-form > .field--name-field-montant-ttc {
grid-column: 3 / 4;
}
/* Field basics */
.figli-ledger-form .form-item__label {
font-size: 0.78rem;
font-weight: 600;
color: var(--flform-label);
margin-bottom: 0.2rem;
display: inline-block;
}
.figli-ledger-form .form-item__description {
font-size: 0.72rem;
color: var(--flform-label);
margin-top: 0.2rem;
}
.figli-ledger-form input.form-element,
.figli-ledger-form select.form-element,
.figli-ledger-form textarea.form-element {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--flform-border);
border-radius: 6px;
padding: 0.32rem 0.5rem;
font-size: 0.88rem;
color: var(--flform-text);
}
/* Not select: Claro positions its dropdown-chevron background-image via
background-position/-repeat/-size, which the `background` shorthand
below would silently reset to their initial values (top-left,
repeating) -- that turned the single right-aligned arrow into a tiled
pattern smeared across the whole field. Selects keep Claro's own
background untouched; only the border/radius/padding above apply. */
.figli-ledger-form input.form-element,
.figli-ledger-form textarea.form-element {
background: transparent;
}
.figli-ledger-form input.form-element:focus,
.figli-ledger-form select.form-element:focus,
.figli-ledger-form textarea.form-element:focus {
outline: 2px solid var(--flform-accent);
outline-offset: 1px;
}
/* Montant TTC: #disabled, computed from HT + TVA (see
figli_compta_ledger_node_presave()) -- a faint fill and full opacity
(overriding the browser's default half-transparent disabled look,
which would make the computed number harder to read) instead of the
plain white/transparent of an editable field. -webkit-text-fill-color
specifically: Chromium renders disabled/autofilled input text through
this property instead of `color` -- left alone, it defaults to a
fixed grey that's illegible against either theme's --flform-bg-subtle
(near-invisible dark-grey-on-dark-grey in dark mode, the actual bug
reported; same risk in light mode against a light fill). */
.figli-ledger-form input:disabled.form-element {
background: var(--flform-bg-subtle);
color: var(--flform-text);
-webkit-text-fill-color: var(--flform-text);
opacity: 1;
cursor: default;
}
.figli-ledger-form input[type="date"].form-element {
width: auto;
min-width: 9.5rem;
}
.figli-ledger-form textarea.form-element {
resize: vertical;
}
/* Multi-value tables (Répartition, Entrée client liée): drop the drag
handle and the "Show row weights" accessibility toggle for it -- row
order never affects the répartition sum or anything displayed
downstream, so with the usual 1-3 rows this is pure clutter. Remove
isn't affected: it stays in the per-row "Toggle Actions" menu. */
.figli-ledger-form .tabledrag-toggle-weight-wrapper,
.figli-ledger-form td.field-multiple-drag {
display: none;
}
.figli-ledger-form table.field-multiple-table {
width: 100%;
border-collapse: collapse;
background: var(--flform-bg-subtle);
border: 1px solid var(--flform-border-soft);
border-radius: 8px;
overflow: hidden;
}
.figli-ledger-form table.field-multiple-table thead th {
text-align: left;
padding: 0.4rem 0.6rem 0.25rem;
}
.figli-ledger-form table.field-multiple-table tbody td {
padding: 0.35rem 0.6rem;
vertical-align: top;
border-top: 1px solid var(--flform-border-soft);
}
.figli-ledger-form .field-multiple-table .form-item__label {
visibility: visible;
}
.figli-ledger-form .paragraphs-subform {
display: flex;
gap: 0.6rem;
}
.figli-ledger-form .paragraphs-subform > .js-form-wrapper {
flex: 1;
min-width: 0;
}
/* Buttons: "Ajouter Répartition" / "Add another item", and the per-row
Remove/Duplicate/Collapse actions -- all plain Drupal form-submit
buttons by default, restyled as a small, consistent set instead of
browser-default grey buttons of varying sizes. */
.figli-ledger-form input.button {
font-size: 0.78rem;
padding: 0.3rem 0.65rem;
border-radius: 5px;
border: 1px solid var(--flform-border);
background: transparent;
color: var(--flform-text);
cursor: pointer;
}
.figli-ledger-form input.button:hover {
border-color: var(--flform-accent);
color: var(--flform-accent);
}
.figli-ledger-form .field-add-more-submit {
margin-top: 0.4rem;
}
.figli-ledger-form .paragraphs-dropdown-toggle {
border: none;
background: transparent;
color: var(--flform-label);
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0.1rem 0.3rem;
}
.figli-ledger-form .paragraphs-dropdown-toggle:hover {
color: var(--flform-accent);
}
/* Closed by default (Claro's own paragraphs.actions.css already sets
display: none here and toggles a sibling .open class on click via
paragraphs.actions.js) -- only restyle the panel's look, and only once
Drupal's own JS has actually opened it. Getting this wrong (e.g.
forcing `display: flex` unconditionally here) makes every "Toggle
Actions" menu render permanently expanded. */
.figli-ledger-form .paragraphs-dropdown.open .paragraphs-dropdown-actions {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.3rem;
border: 1px solid var(--flform-border);
border-radius: 6px;
background: var(--flform-bg-subtle);
}
/* Field group heading style (the Répartition table's own label, inside
its <th>) -- match the rest of the form's label typography instead of
Drupal's default <h4>. */
.figli-ledger-form table.field-multiple-table h4.form-item__label {
font-size: 0.8rem;
margin: 0;
}
/* A répartition row flagged by the sum-mismatch #validate error (see
figli_compta_ledger_validate_repartition()) -- kept subtle (a red
outline, not a solid fill) to match the same red-liseré convention
used for écarts in the /lignes table itself. */
.figli-ledger-form tr.error,
.figli-ledger-form .has-error {
outline: 1px solid var(--flform-danger);
outline-offset: -1px;
}
/* Form actions (Save), rendered by the AJAX dialog's own button pane
rather than here in most cases, but keep a sane baseline for the
rare full-page (non-modal) view of this form. */
.figli-ledger-form .form-actions input.button--primary {
background: var(--flform-accent);
color: #fff;
border-color: var(--flform-accent);
font-weight: 600;
}
@@ -211,6 +211,13 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
_figli_field('node', 'ligne_comptable', 'field_numero_facture', 'N° Facture', 'string', ['max_length' => 64]);
_figli_field('node', 'ligne_comptable', 'field_montant_ht', 'Montant HT (€)', 'decimal', ['precision' => 12, 'scale' => 2], TRUE);
// Scale 4 (not 2) so that reconstructing an existing invoice's exact
// TTC from HT * (1 + TVA/100) round-trips to the centime for the
// overwhelming majority of migrated lines -- see
// figli_compta_ledger_update_8005()'s docblock for the empirical check
// 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]);
_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');
@@ -235,11 +242,19 @@ 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])
->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 4])
->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 5])
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 6, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']])
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 7])
->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 8])
->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
// 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])
->save();
}
@@ -254,11 +269,12 @@ 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_montant_ht', ['type' => 'number_decimal', 'weight' => 4])
->setComponent('field_montant_ttc', ['type' => 'number_decimal', 'weight' => 5])
->setComponent('field_repartition', ['type' => 'entity_reference_revisions_entity_view', 'weight' => 6])
->setComponent('field_notes', ['type' => 'basic_string', 'weight' => 7])
->setComponent('field_flag', ['type' => 'entity_reference_label', 'weight' => 8])
->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])
->save();
}
}
@@ -353,3 +369,127 @@ function figli_compta_ledger_update_8003() {
$view_display->setComponent('field_flag', ['type' => 'entity_reference_label', 'weight' => 8])->save();
}
}
/**
* Adds field_tva (TVA %) to ligne_comptable and switches Montant TTC from
* manual entry to an automatically computed value (see
* figli_compta_ledger_node_presave(): TTC = round(HT * (1 + TVA/100), 2)
* from now on, whenever the line isn't a skip_validation-flagged
* migration save). Existing content is backfilled separately, see
* figli_compta_ledger_update_8005().
*
* Also reweights field_numero_facture (now next to Client, both
* identifying "which invoice/client" this line is about) and
* field_entree_liee (pushed just after it) so the HT/TVA/TTC trio can
* form its own clean three-column row afterwards -- see
* css/ledger-form.css's explicit grid-column rules, which this weight
* order must stay in sync with.
*/
function figli_compta_ledger_update_8004() {
_figli_field('node', 'ligne_comptable', 'field_tva', 'TVA (%)', 'decimal', ['precision' => 8, 'scale' => 4, 'min' => 0]);
// New lines default to 10% (taux intermédiaire) rather than blank --
// most new lines associates enter do carry VAT, so this saves a click
// on the common case instead of silently defaulting to "no VAT" for
// every line unless someone remembers to change it.
$field_tva = FieldConfig::loadByName('node', 'ligne_comptable', 'field_tva');
if ($field_tva && $field_tva->getDefaultValueLiteral() === []) {
$field_tva->setDefaultValue(10)->save();
}
// Weights are plain integers -- entity_display component config
// schema coerces anything else (a fractional weight silently rounds/
// truncates to its integer part on save), so the row-grouping can't be
// expressed as "insert field_tva between HT and TTC" via a fractional
// weight the way it might be elsewhere; every field in this block gets
// an explicit new integer instead.
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
if ($form_display) {
$form_display->setComponent('field_numero_facture', ['type' => 'string_textfield', 'weight' => 3]);
$form_display->setComponent('field_entree_liee', ['type' => 'entity_reference_autocomplete', 'weight' => 4] + (array) $form_display->getComponent('field_entree_liee'));
$form_display->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 5] + (array) $form_display->getComponent('field_montant_ht'));
$form_display->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 7] + (array) $form_display->getComponent('field_montant_ttc'));
// Weight 6 (between HT and TTC) is now the non-field
// "field_tva_rate" select figli_compta_ledger_form_alter() adds --
// field_tva itself moves after Répartition/Notes/Flag's *old*
// positions, past the ones below, so it lands on its own row when
// "Autre" reveals it instead of disrupting the HT/rate/TTC row.
$form_display->setComponent('field_tva', ['type' => 'number', 'weight' => 8] + (array) $form_display->getComponent('field_tva'));
$form_display->setComponent('field_repartition', ['weight' => 9] + (array) $form_display->getComponent('field_repartition'));
$form_display->setComponent('field_notes', ['weight' => 10] + (array) $form_display->getComponent('field_notes'));
$form_display->setComponent('field_flag', ['weight' => 11] + (array) $form_display->getComponent('field_flag'));
$form_display->save();
}
$view_display = EntityViewDisplay::load('node.ligne_comptable.default');
if ($view_display) {
$view_display->setComponent('field_tva', ['type' => 'number_decimal', 'weight' => 8] + (array) $view_display->getComponent('field_tva'));
$view_display->setComponent('field_numero_facture', ['weight' => 3] + (array) $view_display->getComponent('field_numero_facture'));
$view_display->setComponent('field_montant_ht', ['weight' => 5] + (array) $view_display->getComponent('field_montant_ht'));
$view_display->setComponent('field_montant_ttc', ['weight' => 7] + (array) $view_display->getComponent('field_montant_ttc'));
$view_display->setComponent('field_repartition', ['weight' => 9] + (array) $view_display->getComponent('field_repartition'));
$view_display->setComponent('field_notes', ['weight' => 10] + (array) $view_display->getComponent('field_notes'));
$view_display->setComponent('field_flag', ['weight' => 11] + (array) $view_display->getComponent('field_flag'));
$view_display->save();
}
}
/**
* Backfills field_tva for existing ligne_comptable content from the
* existing Montant HT / Montant TTC pair -- never touches Montant TTC
* itself, so every historical value stays exactly as migrated (same
* "never correct historical data" rule as everywhere else in this
* module). Left blank where there's nothing to derive from (no TTC ever
* recorded, or HT is zero).
*
* Why scale 4 on field_tva: checked empirically against all 1556
* existing lignes_comptables before picking it. Of the 946 lines with a
* real (non-zero, HT != TTC) rate, rounding the derived rate to 2
* decimals and reconstructing TTC = HT * (1 + rate/100) mismatched the
* real historical TTC (by more than a centime) for 74 of them --
* unacceptably lossy for what's supposed to be a faithful backfill. At 4
* decimals that drops to 9 (likely genuine blended/multi-rate invoices
* collapsed into a single ligne_comptable, where no single "TVA %" can
* be perfectly exact) -- an acceptable residual, and still purely
* informational since Montant TTC itself is left untouched here either
* way.
*/
function figli_compta_ledger_update_8005() {
$storage = \Drupal::entityTypeManager()->getStorage('node');
$nids = $storage->getQuery()
->accessCheck(FALSE)
->condition('type', 'ligne_comptable')
->execute();
$filled = 0;
$skipped = 0;
// Only field_tva is written here -- montant_ttc is read but never
// set, so this can never trip figli_compta_ledger_node_presave()'s
// sum(répartition) == montant_ht check either way. skip_validation is
// set anyway (a) for consistency with every other bulk-migration
// script in this module and (b) because it also now suppresses the
// TTC auto-computation added in the same presave function, which
// would otherwise silently overwrite the untouched historical TTC the
// moment this script calls save().
\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()
|| !$node->hasField('field_montant_ttc') || $node->get('field_montant_ttc')->isEmpty()) {
$skipped++;
continue;
}
$ht = (float) $node->get('field_montant_ht')->value;
$ttc = (float) $node->get('field_montant_ttc')->value;
if (abs($ht) < 0.0001) {
$skipped++;
continue;
}
$rate = round(($ttc / $ht - 1) * 100, 4);
$node->set('field_tva', $rate);
$node->save();
$filled++;
}
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
return "TVA calculée pour $filled lignes, $skipped laissées vides (pas de Montant TTC renseigné ou Montant HT nul).";
}
@@ -35,6 +35,16 @@ dashboard_compte:
- core/drupal
- figli_compta_ledger/vue
ledger_form:
js:
js/ledger-form.js: {}
css:
theme:
css/ledger-form.css: {}
dependencies:
- core/drupal
- core/once
admin_chrome:
css:
theme:
@@ -35,6 +35,11 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state,
if (!in_array($form_id, ['node_ligne_comptable_form', 'node_ligne_comptable_edit_form'], TRUE)) {
return;
}
// The add and edit forms get different auto-generated classes
// (node-ligne-comptable-form vs. node-ligne-comptable-edit-form) --
// one shared class so css/ledger-form.css can target both without
// duplicating every selector.
$form['#attributes']['class'][] = 'figli-ledger-form';
$form['#validate'][] = 'figli_compta_ledger_validate_repartition';
// Every save must create a revision, unconditionally -- there's no
@@ -71,6 +76,123 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state,
];
}
// "Title" is generic Drupal boilerplate that means nothing to a
// freelancer entering an accounting line -- but home.js/dashboard*.js
// do fall back to it as the displayed "Libellé" when Notes / détail is
// left empty, so it can't just be hidden. Relabel it so that
// relationship is explicit instead of a mystery required field.
if (isset($form['title']['widget'][0]['value'])) {
$form['title']['widget'][0]['value']['#title'] = t('Libellé court');
$form['title']['widget'][0]['value']['#description'] = t('Affiché dans le grand livre si "Notes / détail" ci-dessous est laissé vide.');
}
// Menu settings / URL alias / Authoring information: none of this is
// ever used for a ligne_comptable (no menu links, no path aliases, and
// the acting user + revision timestamp are already captured by the
// forced revision in figli_compta_ledger_node_presave() below) -- just
// noise in a form meant to be a quick numeric entry, not a full content
// editing screen. #access rather than deleting the keys: safe no-op if
// a future field ever gets #group => 'advanced'.
if (isset($form['advanced'])) {
$form['advanced']['#access'] = FALSE;
}
// "Published" is meaningless here too -- there's no draft/unpublished
// workflow for ledger lines, every one of them is expected to count
// immediately. Same pattern as the 'revision' field above: force the
// value rather than exposing a checkbox nobody should ever touch.
if (isset($form['status'])) {
$form['status']['widget']['value']['#default_value'] = TRUE;
$form['status']['#access'] = FALSE;
}
// Montant TTC is computed from Montant HT + TVA (see
// figli_compta_ledger_node_presave()) -- genuinely not editable
// (#disabled, not just readonly: a disabled field can't be focused or
// tabbed into at all, and Form API discards whatever a tampered
// request submits for it in favor of #default_value, same protection
// already used for 'revision' above). js/ledger-form.js can still set
// its .value directly for the live preview -- #disabled only blocks
// user interaction, not script writes.
if (isset($form['field_montant_ttc']['widget'][0]['value'])) {
$form['field_montant_ttc']['widget'][0]['value']['#disabled'] = TRUE;
$form['field_montant_ttc']['widget'][0]['value']['#description'] = t('Calculé automatiquement à partir du montant HT et de la TVA.');
}
// 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
// decimal field underneath for the rest -- crucially including every
// pre-migration line, whose backfilled rate (see
// figli_compta_ledger_update_8005()) is often a blended, non-standard
// percentage that wouldn't match any of these options, and must stay
// editable/visible as the exact figure it is rather than being forced
// to snap to the nearest official rate.
if (isset($form['field_tva']['widget'][0]['value'])) {
$taux_officiels = [
'0' => t('0 % (exonéré)'),
'2.1' => t('2,1 % (taux particulier)'),
'5.5' => t('5,5 % (taux réduit)'),
'10' => t('10 % (taux intermédiaire)'),
'20' => t('20 % (taux normal)'),
];
$tva_field = &$form['field_tva']['widget'][0]['value'];
$current = $tva_field['#default_value'] ?? NULL;
$rate_default = 'autre';
if ($current === NULL || $current === '') {
// 10 % (taux intermédiaire), not 0 -- most new lines associates
// enter do carry VAT, so this saves a click on the common case
// instead of silently defaulting to "no VAT" for every line
// unless someone remembers to change it.
$rate_default = '10';
}
else {
foreach (array_keys($taux_officiels) as $rate) {
if (abs((float) $current - (float) $rate) < 0.0001) {
$rate_default = $rate;
break;
}
}
}
// A plain top-level element, deliberately *not* nested inside
// $form['field_tva'] -- Field API's WidgetBase::extractFormValues()
// reads the entire form_state value at that path as the field's own
// 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
// 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,
// not just on matching #weight values.
$form['field_tva_rate'] = [
'#type' => 'select',
'#title' => t('TVA'),
'#options' => $taux_officiels + ['autre' => t('Autre (préciser)')],
'#default_value' => $rate_default,
'#weight' => 6,
];
$tva_field['#title'] = t('Taux exact (%)');
// Not #states: this element sits outside the field system (see the
// comment above) and, tested in this exact AJAX-loaded modal,
// #states' data-drupal-states attribute gets attached correctly but
// never actually toggles visibility here -- unclear why (a working
// #states dependency already exists a few fields up, on
// field_entree_liee, watching a real Field API select rather than
// this manually-added one), not worth chasing further when
// js/ledger-form.js already has to run custom JS on this form
// anyway for the HT/TVA -> TTC live preview. Plain JS toggle there
// instead -- see figliLedgerTvaRateToggle.
$form['#validate'][] = 'figli_compta_ledger_apply_tva_rate';
}
$form['#attached']['library'][] = 'figli_compta_ledger/ledger_form';
$request = \Drupal::request();
$wrapper_formats = ['drupal_ajax', 'drupal_modal', 'drupal_dialog'];
$is_ajax_modal = in_array($request->query->get('_wrapper_format'), $wrapper_formats, TRUE)
@@ -115,6 +237,28 @@ function figli_compta_ledger_validate_repartition(array &$form, FormStateInterfa
}
}
/**
* Form #validate callback: when a standard French VAT rate was picked from
* the field_tva_rate select (added in figli_compta_ledger_form_alter()),
* copies it into field_tva's own submitted value -- that's the field
* figli_compta_ledger_node_presave() actually reads to compute Montant TTC.
* Left untouched when "Autre" is selected: the real field_tva input,
* shown in that case by js/ledger-form.js's figliLedgerTvaRateToggle,
* already carries whatever the associate typed there.
*
* Runs late enough to matter despite EntityForm::validateForm() building an
* interim entity earlier in the same validate phase (for constraint
* checking only) -- submitForm() rebuilds the entity fresh from
* $form_state's values right before save(), by which point every
* #validate callback, this one included, has already run.
*/
function figli_compta_ledger_apply_tva_rate(array &$form, FormStateInterface $form_state) {
$rate = $form_state->getValue('field_tva_rate');
if ($rate !== NULL && $rate !== 'autre') {
$form_state->setValue(['field_tva', 0, 'value'], $rate);
}
}
/**
* #ajax callback for the node form submit button: close the modal on
* success, or re-render the form (with errors) in place on failure.
@@ -163,8 +307,9 @@ function figli_compta_ledger_node_form_ajax_submit(array $form, FormStateInterfa
* source spreadsheets' raw data, including known répartition mismatches --
* those get surfaced as visible inconsistencies in the dashboard instead of
* being silently fixed. Set the 'figli_compta_ledger.skip_validation' state
* flag around such a bulk import to bypass this check; new lines entered by
* associates through the form are never exempted.
* flag around such a bulk import to bypass this check *and* the Montant TTC
* auto-computation below; new lines entered by associates through the form
* are never exempted from either.
*/
function figli_compta_ledger_node_presave(NodeInterface $node) {
if ($node->bundle() !== 'ligne_comptable') {
@@ -184,7 +329,7 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
// silently mislabeling every edit with its predecessor's save time.
$node->setRevisionCreationTime(\Drupal::time()->getRequestTime());
if (!$node->hasField('field_montant_ht') || !$node->hasField('field_repartition')) {
if (!$node->hasField('field_montant_ht')) {
return;
}
if (\Drupal::state()->get('figli_compta_ledger.skip_validation', FALSE)) {
@@ -192,6 +337,38 @@ 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;
// 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.
if ($node->hasField('field_montant_ttc')) {
$ht_or_tva_changed = $node->isNew();
if (!$ht_or_tva_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;
}
if ($ht_or_tva_changed) {
$node->set('field_montant_ttc', round($montant_ht * (1 + $tva / 100), 2));
}
}
if (!$node->hasField('field_repartition')) {
return;
}
$somme = 0.0;
foreach ($node->get('field_repartition')->referencedEntities() as $paragraph) {
if ($paragraph->hasField('field_montant') && !$paragraph->get('field_montant')->isEmpty()) {
@@ -0,0 +1,108 @@
/**
* @file
* Live preview of Montant TTC = Montant HT * (1 + TVA/100) on the ligne
* comptable add/edit form -- purely cosmetic, so the associate sees the
* computed amount before saving instead of a blank/stale readonly field.
* The authoritative computation is server-side, in
* figli_compta_ledger_node_presave() (figli_compta_ledger.module) --
* whatever ends up in this input on submit is discarded and recomputed
* there regardless of whether this script even ran.
*/
(function (Drupal, once) {
'use strict';
Drupal.behaviors.figliLedgerTvaCalc = {
attach: function (context) {
// once()'s selector match runs through context.querySelectorAll(),
// which -- like any querySelectorAll -- never matches the context
// node itself, only its descendants. The AJAX dialog opening this
// form passes the <form class="figli-ledger-form"> element itself
// as context, so a selector of '.figli-ledger-form' silently
// matched nothing and this behavior never ran. Selecting the HT
// input directly sidesteps that: it's always a genuine descendant
// of whatever context gets passed (document on a full page load,
// the form itself from the dialog, or anything in between).
once('figli-ledger-tva-calc', '[name="field_montant_ht[0][value]"]', context).forEach(function (ht) {
var form = ht.closest('form');
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]"]');
if (!tva || !ttc) {
return;
}
// The rate select (when not "Autre") is the actual source of
// truth here -- figli_compta_ledger_apply_tva_rate() copies its
// value into field_tva server-side on submit, but that happens
// too late to matter for a live client-side preview.
function currentTva() {
if (rate && rate.value !== 'autre') {
return parseFloat(rate.value);
}
return parseFloat(tva.value);
}
function recompute() {
var htValue = parseFloat(ht.value);
if (isNaN(htValue)) {
return;
}
var tvaValue = currentTva();
if (isNaN(tvaValue)) {
tvaValue = 0;
}
ttc.value = (htValue * (1 + tvaValue / 100)).toFixed(2);
}
// Deliberately not called once up front on an existing (edit)
// line: the stored Montant TTC may predate this computed-field
// architecture and not be perfectly reproducible from HT/TVA to
// 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).
ht.addEventListener('input', recompute);
tva.addEventListener('input', recompute);
if (rate) {
rate.addEventListener('change', recompute);
}
});
}
};
/**
* Shows/hides the real "Taux exact (%)" field_tva input based on the
* field_tva_rate select added in figli_compta_ledger_form_alter() --
* plain JS rather than #states: tested and confirmed #states' own
* data-drupal-states attribute gets attached correctly here but never
* actually toggles visibility, for reasons not tracked down (a
* working #states dependency already exists a few fields up, on
* field_entree_liee, watching a real Field API element rather than
* this manually-added select -- that one difference is the only lead
* so far). Not worth blocking on since this form already needs custom
* JS anyway for the TTC live preview above.
*/
Drupal.behaviors.figliLedgerTvaRateToggle = {
attach: function (context) {
once('figli-ledger-tva-rate-toggle', '[name="field_tva_rate"]', context).forEach(function (select) {
var form = select.closest('form');
var exact = form.querySelector('.field--name-field-tva');
if (!exact) {
return;
}
function toggle() {
exact.style.display = select.value === 'autre' ? '' : 'none';
}
select.addEventListener('change', toggle);
toggle();
});
}
};
})(Drupal, once);