Répartition assistée sur le formulaire + widget compacté

- ledger-form.js : nouveau behavior figliLedgerRepartition --
  pré-remplit les montants de répartition (1re ligne = HT entier,
  chaque ajout partage au centime avec report du reste sur les lignes
  suivantes), valeurs saisies manuellement ou chargées de la base
  « figées » (plus jamais déplacées), écart en direct dans la cellule
  de titre du widget (miroir exact du round(HT − somme, 2) et de la
  tolérance 0,01 du presave). Zéro changement PHP : #validate et
  node_presave() restent l'autorité. Les verrous vivent hors du DOM
  (survie aux re-rendus AJAX du widget) ; la détection manuel/assist
  repose sur le fait qu'une écriture programmatique ne déclenche pas
  d'événement input.
- ledger-form.css : compactage du widget -- titre « Répartition » par
  ligne, bouton Collapse et colonne Order masqués, paragraph-top en
  absolu (coin haut-droit, zéro hauteur), une seule ligne par
  répartition « Compte [input] Montant (€) [input] » où l'input du
  Compte est contraint en pourcentage de son wrapper claro-autocomplete
  (size=60 : il débordait sur le libellé Montant), header réordonné
  titre / écart / menu trois-points, inputs visibles au repos
  (--flform-bg, atténué en mode sombre), marqueur « figé ».
- install : update_8015 (features du widget vidées) puis update_8016
  (collapse_edit_all rétabli à la demande, duplicate reste off) +
  settings de l'install fraîche synchronisés.
This commit is contained in:
2026-09-09 16:44:46 +02:00
parent d2f3179f01
commit 5eca3804bf
3 changed files with 453 additions and 5 deletions
@@ -20,6 +20,7 @@
--flform-border-soft: #e8eaed;
--flform-label: #4b5563;
--flform-text: #1a1a1a;
--flform-bg: #ffffff;
--flform-bg-subtle: #f7f8fa;
--flform-accent: #2f6f4f;
--flform-danger: #b3261e;
@@ -43,6 +44,7 @@ html.gin--dark-mode .figli-ledger-form {
--flform-border-soft: #333438;
--flform-label: #a1a5ab;
--flform-text: #e8e9ea;
--flform-bg: #3a3b40;
--flform-bg-subtle: #2a2b2e;
--flform-accent: #5fb98a;
--flform-danger: #ff6b6b;
@@ -212,10 +214,10 @@ html.gin--dark-mode .figli-ledger-form {
}
.figli-ledger-form table.field-multiple-table thead th {
text-align: left;
padding: 0.4rem 0.6rem 0.25rem;
padding: 0.3rem 0.5rem 0.2rem;
}
.figli-ledger-form table.field-multiple-table tbody td {
padding: 0.35rem 0.6rem;
padding: 0.2rem 0.5rem;
vertical-align: top;
border-top: 1px solid var(--flform-border-soft);
}
@@ -224,12 +226,25 @@ html.gin--dark-mode .figli-ledger-form {
}
.figli-ledger-form .paragraphs-subform {
display: flex;
gap: 0.6rem;
gap: 0.5rem;
/* Réserve le coin haut-droit (bouton Remove du paragraph-top, en
position:absolute) : les inputs ne passent plus dessous et
rétrécissent d'autant. */
padding-right: 4.4rem;
}
.figli-ledger-form .paragraphs-subform > .js-form-wrapper {
flex: 1;
min-width: 0;
}
/* Le Compte (nom, souvent long) prend une part plus large que le
Montant (chiffre court) -- tous deux plus étroits qu'avant, la zone
du Remove étant réservée ci-dessus. */
.figli-ledger-form .paragraphs-subform > .js-form-wrapper.field--name-field-compte {
flex: 1.6;
}
.figli-ledger-form .paragraphs-subform > .js-form-wrapper.field--name-field-montant {
flex: 1;
}
/* Buttons: "Ajouter Répartition" / "Add another item", and the per-row
Remove/Duplicate/Collapse actions -- all plain Drupal form-submit
@@ -249,7 +264,7 @@ html.gin--dark-mode .figli-ledger-form {
color: var(--flform-accent);
}
.figli-ledger-form .field-add-more-submit {
margin-top: 0.4rem;
margin-top: 0.25rem;
}
.figli-ledger-form .paragraphs-dropdown-toggle {
border: none;
@@ -287,6 +302,164 @@ html.gin--dark-mode .figli-ledger-form {
margin: 0;
}
/* ==== Répartition : compactage du widget (une ligne = Compte + Montant,
rien d'autre) + assist (PLAN-repartition-assistee.md) ==== */
/* Une ligne de répartition ne doit montrer que ses deux champs. Le
titre "Répartition" de chaque ligne est redondant (le titre du champ
est déjà en thead), le bouton Collapse n'a pas de sens (edit_mode:
open, les lignes sont toujours ouvertes -- et le CSS les masque de
toute façon côté JS), et paragraph-info/paragraph-summary sont rendus
vides pour ce bundle : chacun coûtait une ligne de bruit vertical. */
.figli-ledger-form .field--name-field-repartition .paragraph-type,
.figli-ledger-form .field--name-field-repartition .paragraph-info,
.figli-ledger-form .field--name-field-repartition .paragraph-summary,
.figli-ledger-form .field--name-field-repartition .paragraphs-icon-button-collapse {
display: none;
}
/* Le "paragraph-top" ne porte plus que le menu d'actions de la ligne
(Remove) : en ABSOLU dans le coin haut-droit de la ligne (td en
position:relative), aligné sur le padding horizontal de la cellule --
il n'ajoute donc AUCUNE hauteur, la ligne se réduit à ses deux
champs. */
.figli-ledger-form .field--name-field-repartition tr.paragraph-type--repartition > td {
position: relative;
/* Aucun padding vertical sur les lignes de répartition (demande
explicite) : la hauteur de ligne vient uniquement des champs, le
bordure-top reste comme séparateur. */
padding-top: 0;
padding-bottom: 0;
}
.figli-ledger-form .field--name-field-repartition .paragraph-top {
position: absolute;
top: 0.15rem;
right: 0.5rem;
z-index: 2;
display: flex;
align-items: center;
gap: 0.3rem;
margin: 0;
}
.figli-ledger-form .field--name-field-repartition .paragraph-top .paragraphs-actions {
margin: 0;
}
/* Colonne "Order" (poids des lignes) des tables multi-valeurs : l'ordre
ne compte jamais ici (cf. handles de drag déjà masqués plus haut),
deux champs de largeur récupérés. */
.figli-ledger-form .field-multiple-table thead th:last-child,
.figli-ledger-form .field-multiple-table td.delta-order {
display: none;
}
/* Menu "Toggle Actions" du thead : il porte le bouton "Collapse / Edit
all" (feature rétablie par update_8016) -- seul le mode "Drag & drop"
y est masqué, un réordonnancement sans objet (handles masqués plus
haut). Le menu par ligne (Remove) est hors de ce sélecteur. */
.figli-ledger-form .field--name-field-repartition thead input[name="field_repartition_dragdrop_mode"] {
display: none;
}
/* Barre de titre du widget : titre "Répartition" À GAUCHE, écart AU
MILIEU, menu trois-points À DROITE (l'ordre DOM est titre, actions,
puis l'écart injecté par JS, d'où les order). */
.figli-ledger-form .field--name-field-repartition th.field-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
}
.figli-ledger-form .field--name-field-repartition th.field-label h4 {
order: 0;
margin: 0;
}
.figli-ledger-form .figli-repartition-ecart {
order: 1;
margin: 0;
white-space: nowrap;
font-size: 0.8rem;
font-weight: 400;
color: var(--flform-label);
}
.figli-ledger-form .field--name-field-repartition th.field-label .paragraphs-actions {
order: 2;
}
.figli-ledger-form .figli-repartition-ecart-val {
font-weight: 600;
}
.figli-ledger-form .figli-repartition-ecart.is-ok .figli-repartition-ecart-val {
color: var(--flform-accent);
}
.figli-ledger-form .figli-repartition-ecart.is-ko .figli-repartition-ecart-val {
color: var(--flform-danger);
}
/* Une ligne par répartition : "Compte [input] Montant (€) [input]".
Le piège : le form-item de Compte n'a PAS l'input pour enfant direct
-- claro l'emballe dans div.claro-autocomplete (le Montant, lui, a
l'input direct). Sans flex/min-width sur ce wrapper, l'input
(size=60) force sa largeur native, pousse le Montant hors de la
cellule et les deux champs disparaissent : chaque niveau doit être
flex et compressible. */
.figli-ledger-form .paragraphs-subform .form-item {
display: flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
}
.figli-ledger-form .paragraphs-subform .form-item__label {
margin: 0;
white-space: nowrap;
}
.figli-ledger-form .paragraphs-subform .claro-autocomplete {
display: block;
flex: 1;
min-width: 0;
}
/* L'input du Compte (size="60", ~420px de largeur intrinsèque) : la
chaîne flex/min-width ne suffit pas à le contenir de façon fiable à
tous les niveaux -- contrainte dure en pourcentage de son wrapper à
la place : physiquement incapable de déborder sur la colonne du
Montant, quoi que dise l'attribut size. */
.figli-ledger-form .paragraphs-subform .claro-autocomplete input.form-element {
width: 100%;
max-width: 100%;
}
/* Inputs visibles au repos (fond opaque + bordure franche, les deux
modes via --flform-bg) et compressibles. flex:1 s'applique : au
Montant comme item direct du form-item, au Compte comme item du
claro-autocomplete flex ci-dessus. */
.figli-ledger-form .paragraphs-subform input.form-element {
flex: 1;
min-width: 0;
width: auto;
max-width: none;
background: var(--flform-bg);
border: 1px solid var(--flform-border);
color: var(--flform-text);
-webkit-text-fill-color: var(--flform-text);
}
/* Marqueur "figé" (ledger-form.js) : une valeur que l'assist ne
touchera plus -- saisie manuelle, ou chargée de la base. En item flex
(le form-item est en ligne ci-dessus), il suit l'input ; pointillé =
même convention que la référence bancaire, "valeur à regarder, pas à
retaper". */
.figli-ledger-form .figli-repartition-locked::after {
content: "figé";
flex: none;
align-self: center;
font-size: 0.64rem;
line-height: 1;
padding: 0.16rem 0.28rem;
border-radius: 5px;
border: 1px dashed var(--flform-border);
color: var(--flform-label);
}
/* A répartition row flagged by the sum-mismatch #validate error (see
figli_compta_ledger_validate_repartition()) -- kept subtle (a red
outline, not a solid fill) to match the same red-liseré convention
@@ -313,7 +313,7 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
// 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' => 10])
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 11, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']])
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 11, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button', 'features' => ['collapse_edit_all' => 'collapse_edit_all']]])
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 12])
->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 13])
->save();
@@ -929,3 +929,47 @@ function figli_compta_ledger_update_8014() {
return 'field_montant_releve placé sous Montant TTC (weights décalés).';
}
/**
* Empties the Paragraphs widget's "features" setting for
* field_repartition: with duplicate and collapse_edit_all off, the
* widget stops rendering the per-row Duplicate action and the thead's
* "Collapse / Edit all" -- pure noise on a 1-3 row répartition whose
* order never matters (see ledger-form.css's compacting rules for the
* rest: per-row title and Collapse button are hidden in CSS, they have
* no widget setting).
*/
function figli_compta_ledger_update_8015() {
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
if ($form_display) {
$component = $form_display->getComponent('field_repartition');
if ($component) {
$component['settings']['features'] = [];
$form_display->setComponent('field_repartition', $component);
$form_display->save();
}
}
return 'Widget Répartition compacté (features duplicate/collapse_edit_all désactivées).';
}
/**
* Re-enables the Paragraphs widget's collapse_edit_all feature (the
* "Collapse / Edit all" action in the widget's title cell) -- removed
* along with duplicate by update_8015, asked back by the associates
* the same day. Duplicate stays off: it has no use on a 1-3 row
* répartition whose order never matters.
*/
function figli_compta_ledger_update_8016() {
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
if ($form_display) {
$component = $form_display->getComponent('field_repartition');
if ($component) {
$component['settings']['features'] = ['collapse_edit_all' => 'collapse_edit_all'];
$form_display->setComponent('field_repartition', $component);
$form_display->save();
}
}
return 'Bouton "Collapse / Edit all" rétabli sur le widget Répartition (duplicate reste désactivé).';
}
@@ -136,4 +136,235 @@
});
}
};
/**
* Répartition assistée (see PLAN-repartition-assistee.md): auto-fills
* the répartition amounts so the associate never does the small
* arithmetic by hand. Purely client-side pre-filling -- the server
* stays the authority (figli_compta_ledger_validate_repartition() +
* figli_compta_ledger_node_presave() unchanged), same philosophy as
* the TTC live preview above: with JS off, the form behaves exactly
* as before.
*
* Rules (plan §Comportement):
* - a value loaded from the database, or typed by the user, is
* "figée" (pinned) and never moved again;
* - the remaining rows split (HT somme(figées)) to the centime,
* rounding remainders carried by the later rows, 0 when the
* remainder is negative;
* - triggers: row added/removed (Paragraphs AJAX re-render ->
* behavior attach), HT change, manual amount edit;
* - live "Réparti / Écart" line under the widget, mirroring the
* server's round(HT somme, 2) and its 0.01 tolerance.
*
* "Manual vs assist" detection rests on a DOM property: assigning
* input.value programmatically fires no event, only a real user edit
* fires 'input'. Locks therefore survive the widget's full AJAX
* re-renders (every add/remove rebuilds the inputs) because they live
* in this closure, keyed by input name (deltas are stable -- drag
* handles are hidden, order never matters); the assisted{} memory is
* what makes a never-assisted value (i.e. anything loaded from the
* database) figée by construction, and keeps a stale lock harmless
* after a row deletion reindexes the deltas.
*/
Drupal.behaviors.figliLedgerRepartition = {
attach: function (context) {
// Behaviors attach on every AJAX response with the replaced
// fragment as context -- the form itself only once (which is when
// the delegated listener below is set up), any later attach is a
// Paragraphs re-render and just needs a refresh. Like the TVA
// behavior's comment explains, context may BE the form (modal) or
// a fragment INSIDE it (add/remove) -- collect all three cases,
// then dedupe through once().
var ctx = (context && (context.nodeType === 1 || context.nodeType === 9)) ? context : document;
var forms = [];
if (ctx.nodeType === 1 && ctx.matches('.figli-ledger-form')) {
forms.push(ctx);
}
if (ctx.querySelectorAll) {
Array.prototype.forEach.call(ctx.querySelectorAll('.figli-ledger-form'), function (f) {
forms.push(f);
});
}
if (!forms.length && ctx.nodeType === 1 && ctx.closest) {
var ancestor = ctx.closest('.figli-ledger-form');
if (ancestor) {
forms.push(ancestor);
}
}
forms.forEach(function (form) {
if (once('figli-ledger-repartition', form).length) {
form.figliLedgerRepartition = initRepartitionAssist(form);
}
if (form.figliLedgerRepartition) {
form.figliLedgerRepartition.refresh();
}
});
}
};
function initRepartitionAssist(form) {
// input.name -> true (manual edit at some point, never move again).
var figees = {};
// input.name -> last value WE wrote (the assist's own writes).
var assisted = {};
var MONTANT_SEL = 'input[name$="[field_montant][0][value]"]';
var HT_NAME = 'field_montant_ht[0][value]';
function round2(x) {
return Math.round(x * 100) / 100;
}
function eur(x) {
return (x < 0 ? '-' : '') + Math.abs(x).toFixed(2).replace('.', ',') + ' €';
}
function widget() {
return form.querySelector('.field--name-field-repartition');
}
function amounts() {
var w = widget();
if (!w) {
return [];
}
return Array.prototype.slice.call(w.querySelectorAll(MONTANT_SEL));
}
function htValue() {
var ht = form.querySelector('[name="' + HT_NAME + '"]');
var v = ht ? parseFloat(ht.value) : NaN;
return isNaN(v) ? NaN : v;
}
// The écart lives inside the table's own th.field-label ("Répartition"
// heading cell), next to the Collapse-all menu -- requested layout:
// everything the réparation needs to know, in the table's title bar.
// That puts it INSIDE the widget's AJAX re-render zone (every
// add/remove rebuilds the table), so the element gets wiped and
// recreated on each refresh -- exactly what refresh() below does.
function ecartRow() {
var existing = form.querySelector('.figli-repartition-ecart');
if (existing) {
return existing;
}
var w = widget();
var th = w ? w.querySelector('th.field-label') : null;
if (!th) {
return null;
}
var span = document.createElement('span');
span.className = 'figli-repartition-ecart';
span.innerHTML = '<span class="figli-repartition-ecart-sum"></span> · <span class="figli-repartition-ecart-val"></span>';
th.appendChild(span);
return span;
}
function writeAssisted(input, value) {
var s = value.toFixed(2);
if (input.value !== s) {
input.value = s;
}
assisted[input.name] = s;
}
function refresh() {
var row = ecartRow();
var inputs = amounts();
if (row) {
// Hidden until the first répartition exists, wiped with the rest
// of the table on add/remove -- re-created on the next refresh.
row.style.display = inputs.length ? '' : 'none';
}
if (!inputs.length) {
return;
}
var ht = htValue();
// Classify + mark figées + sum their values.
var sommeFigees = 0;
var libres = [];
inputs.forEach(function (input) {
var value = input.value.trim();
var figee = !!figees[input.name]
|| (value !== '' && String(assisted[input.name]) !== value);
var item = input.closest('.form-item');
if (item) {
item.classList.toggle('figli-repartition-locked', figee);
}
if (figee) {
var v = parseFloat(value);
if (!isNaN(v)) {
sommeFigees = round2(sommeFigees + v);
}
}
else {
libres.push(input);
}
});
// Distribute: sequential split, each row gets round(restant /
// restantes), remainder carried by the later rows -- sum exact to
// the centime (a naive HT/n leaves 0,03€ of écart on 3 rows).
var restant = isNaN(ht) ? NaN : round2(ht - sommeFigees);
libres.forEach(function (input, i) {
if (isNaN(restant)) {
// No HT to distribute yet: clear our own previous writes only.
if (input.value !== '' && String(assisted[input.name]) === input.value) {
input.value = '';
}
return;
}
var n = libres.length - i;
var v = restant <= 0 ? 0 : round2(restant / n);
writeAssisted(input, v);
restant = round2(restant - v);
});
// Live écart, mirroring the server exactly (same rounding, same
// 0.01 tolerance as figli_compta_ledger_node_presave()).
var somme = 0;
inputs.forEach(function (input) {
var v = parseFloat(input.value);
if (!isNaN(v)) {
somme = round2(somme + v);
}
});
var ecart = isNaN(ht) ? NaN : round2(ht - somme);
if (!row) {
return;
}
row.querySelector('.figli-repartition-ecart-sum').textContent = 'Réparti : ' + eur(somme);
var val = row.querySelector('.figli-repartition-ecart-val');
val.textContent = isNaN(ecart) ? 'Écart : —' : 'Écart : ' + eur(ecart);
row.classList.toggle('is-ok', !isNaN(ecart) && Math.abs(ecart) <= 0.01);
row.classList.toggle('is-ko', !isNaN(ecart) && Math.abs(ecart) > 0.01);
}
// One delegated listener on the form (survives every Paragraphs
// re-render, unlike per-input listeners). Programmatic writes above
// fire no event, so any 'input' seen here IS a human edit: lock it
// -- or unlock when cleared, so an emptied row becomes free again.
form.addEventListener('input', function (e) {
var t = e.target;
if (!t.matches) {
return;
}
if (t.matches(MONTANT_SEL)) {
if (t.value.trim() === '') {
delete figees[t.name];
}
else {
figees[t.name] = true;
}
refresh();
}
else if (t.name === HT_NAME) {
refresh();
}
});
return { refresh: refresh };
}
})(Drupal, once);