From 5eca3804bf9d787501db4ddc02661e6a2da30884 Mon Sep 17 00:00:00 2001 From: bach Date: Wed, 9 Sep 2026 16:44:46 +0200 Subject: [PATCH] =?UTF-8?q?R=C3=A9partition=20assist=C3=A9e=20sur=20le=20f?= =?UTF-8?q?ormulaire=20+=20widget=20compact=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../figli_compta_ledger/css/ledger-form.css | 181 +++++++++++++- .../figli_compta_ledger.install | 46 +++- .../figli_compta_ledger/js/ledger-form.js | 231 ++++++++++++++++++ 3 files changed, 453 insertions(+), 5 deletions(-) diff --git a/web/modules/custom/figli_compta_ledger/css/ledger-form.css b/web/modules/custom/figli_compta_ledger/css/ledger-form.css index 7b264b3..a6276ef 100644 --- a/web/modules/custom/figli_compta_ledger/css/ledger-form.css +++ b/web/modules/custom/figli_compta_ledger/css/ledger-form.css @@ -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 diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.install b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.install index a261f8e..1f9473c 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.install +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.install @@ -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é).'; +} diff --git a/web/modules/custom/figli_compta_ledger/js/ledger-form.js b/web/modules/custom/figli_compta_ledger/js/ledger-form.js index c29bd69..cb7e290 100644 --- a/web/modules/custom/figli_compta_ledger/js/ledger-form.js +++ b/web/modules/custom/figli_compta_ledger/js/ledger-form.js @@ -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 = ' · '; + 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);