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
@@ -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);