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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user