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