diff --git a/config/sync/core.entity_form_display.node.ligne_comptable.default.yml b/config/sync/core.entity_form_display.node.ligne_comptable.default.yml index 0a31aa3..0c76383 100644 --- a/config/sync/core.entity_form_display.node.ligne_comptable.default.yml +++ b/config/sync/core.entity_form_display.node.ligne_comptable.default.yml @@ -58,7 +58,7 @@ content: third_party_settings: { } field_flag: type: entity_reference_autocomplete_tags - weight: 10 + weight: 11 region: content settings: match_operator: CONTAINS @@ -82,7 +82,7 @@ content: third_party_settings: { } field_notes: type: string_textarea - weight: 9 + weight: 10 region: content settings: rows: 5 @@ -98,7 +98,7 @@ content: third_party_settings: { } field_repartition: type: paragraphs - weight: 8 + weight: 9 region: content settings: title: Répartition @@ -116,7 +116,7 @@ content: third_party_settings: { } field_tva: type: number - weight: 6 + weight: 8 region: content settings: placeholder: '' diff --git a/config/sync/core.entity_view_display.node.ligne_comptable.default.yml b/config/sync/core.entity_view_display.node.ligne_comptable.default.yml index 2d5cfb8..bb898d5 100644 --- a/config/sync/core.entity_view_display.node.ligne_comptable.default.yml +++ b/config/sync/core.entity_view_display.node.ligne_comptable.default.yml @@ -56,7 +56,7 @@ content: settings: link: true third_party_settings: { } - weight: 10 + weight: 11 region: content field_montant_ht: type: number_decimal @@ -85,7 +85,7 @@ content: label: above settings: { } third_party_settings: { } - weight: 9 + weight: 10 region: content field_numero_facture: type: string @@ -103,7 +103,7 @@ content: view_mode: default link: '' third_party_settings: { } - weight: 8 + weight: 9 region: content field_tva: type: number_decimal @@ -114,7 +114,7 @@ content: scale: 2 prefix_suffix: true third_party_settings: { } - weight: 6 + weight: 8 region: content field_type_ligne: type: list_default 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 587bc28..3abb417 100644 --- a/web/modules/custom/figli_compta_ledger/css/ledger-form.css +++ b/web/modules/custom/figli_compta_ledger/css/ledger-form.css @@ -88,9 +88,18 @@ html.gin--dark-mode .figli-ledger-form { .figli-ledger-form > .field--name-field-montant-ht { grid-column: 1 / 2; } -.figli-ledger-form > .field--name-field-tva { +/* The field_tva_rate select (added in figli_compta_ledger_form_alter(), + not a real field so it gets no field--name-* class) fills the middle + slot of the HT/TVA/TTC row instead -- see that function's #weight + comment for why this specific ordering (weight 6, between HT's 5 and + TTC's 7) matters, not just matching column numbers. field_tva itself + only appears -- full width, its own row -- when "Autre" is picked. */ +.figli-ledger-form > .js-form-item-field-tva-rate { grid-column: 2 / 3; } +.figli-ledger-form > .field--name-field-tva { + grid-column: 1 / -1; +} .figli-ledger-form > .field--name-field-montant-ttc { grid-column: 3 / 4; } @@ -135,12 +144,15 @@ html.gin--dark-mode .figli-ledger-form { outline: 2px solid var(--flform-accent); outline-offset: 1px; } -/* Montant TTC: readonly, computed from HT + TVA (see - figli_compta_ledger_node_presave()) -- a faint fill instead of the - plain white/transparent of an editable field reads as "there but not - for you to type in" without looking disabled/greyed-out. */ -.figli-ledger-form input[readonly].form-element { +/* Montant TTC: #disabled, computed from HT + TVA (see + figli_compta_ledger_node_presave()) -- a faint fill and full opacity + (overriding the browser's default half-transparent disabled look, + which would make the computed number harder to read) instead of the + plain white/transparent of an editable field. */ +.figli-ledger-form input:disabled.form-element { background: var(--flform-bg-subtle); + color: var(--flform-text); + opacity: 1; cursor: default; } .figli-ledger-form input[type="date"].form-element { 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 85280c1..b18675c 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.install +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.install @@ -243,11 +243,18 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() { ->setComponent('field_client', ['type' => 'entity_reference_autocomplete', 'weight' => 2]) ->setComponent('field_numero_facture', ['type' => 'string_textfield', 'weight' => 3]) ->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 5]) - ->setComponent('field_tva', ['type' => 'number', 'weight' => 6]) ->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 7]) - ->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 8, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']]) - ->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 9]) - ->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 10]) + // field_tva sits *after* Montant TTC, not between HT and TTC -- + // figli_compta_ledger_form_alter() inserts a non-field + // "field_tva_rate" select at weight 6 (a select of the official + // French VAT rates) to fill that visual slot instead; this real + // field only becomes visible (still at its own weight, on its own + // 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' => 8]) + ->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 9, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']]) + ->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 10]) + ->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 11]) ->save(); } @@ -263,11 +270,11 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() { ->setComponent('field_client', ['type' => 'entity_reference_label', 'weight' => 2]) ->setComponent('field_numero_facture', ['type' => 'string', 'weight' => 3]) ->setComponent('field_montant_ht', ['type' => 'number_decimal', 'weight' => 5]) - ->setComponent('field_tva', ['type' => 'number_decimal', 'weight' => 6]) ->setComponent('field_montant_ttc', ['type' => 'number_decimal', 'weight' => 7]) - ->setComponent('field_repartition', ['type' => 'entity_reference_revisions_entity_view', 'weight' => 8]) - ->setComponent('field_notes', ['type' => 'basic_string', 'weight' => 9]) - ->setComponent('field_flag', ['type' => 'entity_reference_label', 'weight' => 10]) + ->setComponent('field_tva', ['type' => 'number_decimal', 'weight' => 8]) + ->setComponent('field_repartition', ['type' => 'entity_reference_revisions_entity_view', 'weight' => 9]) + ->setComponent('field_notes', ['type' => 'basic_string', 'weight' => 10]) + ->setComponent('field_flag', ['type' => 'entity_reference_label', 'weight' => 11]) ->save(); } } @@ -401,23 +408,28 @@ function figli_compta_ledger_update_8004() { $form_display->setComponent('field_numero_facture', ['type' => 'string_textfield', 'weight' => 3]); $form_display->setComponent('field_entree_liee', ['type' => 'entity_reference_autocomplete', 'weight' => 4] + (array) $form_display->getComponent('field_entree_liee')); $form_display->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 5] + (array) $form_display->getComponent('field_montant_ht')); - $form_display->setComponent('field_tva', ['type' => 'number', 'weight' => 6] + (array) $form_display->getComponent('field_tva')); $form_display->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 7] + (array) $form_display->getComponent('field_montant_ttc')); - $form_display->setComponent('field_repartition', ['weight' => 8] + (array) $form_display->getComponent('field_repartition')); - $form_display->setComponent('field_notes', ['weight' => 9] + (array) $form_display->getComponent('field_notes')); - $form_display->setComponent('field_flag', ['weight' => 10] + (array) $form_display->getComponent('field_flag')); + // Weight 6 (between HT and TTC) is now the non-field + // "field_tva_rate" select figli_compta_ledger_form_alter() adds -- + // field_tva itself moves after Répartition/Notes/Flag's *old* + // positions, past the ones below, so it lands on its own row when + // "Autre" reveals it instead of disrupting the HT/rate/TTC row. + $form_display->setComponent('field_tva', ['type' => 'number', 'weight' => 8] + (array) $form_display->getComponent('field_tva')); + $form_display->setComponent('field_repartition', ['weight' => 9] + (array) $form_display->getComponent('field_repartition')); + $form_display->setComponent('field_notes', ['weight' => 10] + (array) $form_display->getComponent('field_notes')); + $form_display->setComponent('field_flag', ['weight' => 11] + (array) $form_display->getComponent('field_flag')); $form_display->save(); } $view_display = EntityViewDisplay::load('node.ligne_comptable.default'); if ($view_display) { - $view_display->setComponent('field_tva', ['type' => 'number_decimal', 'weight' => 6] + (array) $view_display->getComponent('field_tva')); + $view_display->setComponent('field_tva', ['type' => 'number_decimal', 'weight' => 8] + (array) $view_display->getComponent('field_tva')); $view_display->setComponent('field_numero_facture', ['weight' => 3] + (array) $view_display->getComponent('field_numero_facture')); $view_display->setComponent('field_montant_ht', ['weight' => 5] + (array) $view_display->getComponent('field_montant_ht')); $view_display->setComponent('field_montant_ttc', ['weight' => 7] + (array) $view_display->getComponent('field_montant_ttc')); - $view_display->setComponent('field_repartition', ['weight' => 8] + (array) $view_display->getComponent('field_repartition')); - $view_display->setComponent('field_notes', ['weight' => 9] + (array) $view_display->getComponent('field_notes')); - $view_display->setComponent('field_flag', ['weight' => 10] + (array) $view_display->getComponent('field_flag')); + $view_display->setComponent('field_repartition', ['weight' => 9] + (array) $view_display->getComponent('field_repartition')); + $view_display->setComponent('field_notes', ['weight' => 10] + (array) $view_display->getComponent('field_notes')); + $view_display->setComponent('field_flag', ['weight' => 11] + (array) $view_display->getComponent('field_flag')); $view_display->save(); } } diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module index f5cc7ea..ca6f009 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module @@ -107,16 +107,86 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state, } // Montant TTC is computed from Montant HT + TVA (see - // figli_compta_ledger_node_presave()) -- readonly rather than #disabled - // so js/ledger-form.js can still show a live preview as the associate - // types, and so the field still visually reads as "there but not for - // you to edit" instead of vanishing. The actual value saved is always - // recomputed server-side regardless of what this input holds. + // figli_compta_ledger_node_presave()) -- genuinely not editable + // (#disabled, not just readonly: a disabled field can't be focused or + // tabbed into at all, and Form API discards whatever a tampered + // request submits for it in favor of #default_value, same protection + // already used for 'revision' above). js/ledger-form.js can still set + // its .value directly for the live preview -- #disabled only blocks + // user interaction, not script writes. if (isset($form['field_montant_ttc']['widget'][0]['value'])) { - $form['field_montant_ttc']['widget'][0]['value']['#attributes']['readonly'] = 'readonly'; + $form['field_montant_ttc']['widget'][0]['value']['#disabled'] = TRUE; $form['field_montant_ttc']['widget'][0]['value']['#description'] = t('Calculé automatiquement à partir du montant HT et de la TVA.'); } + // TVA: a select of the official French rates covers the overwhelming + // majority of new lines with one click, instead of associates having + // to know/type "20" or "5.5" by heart. "Autre" reveals the real + // decimal field underneath for the rest -- crucially including every + // pre-migration line, whose backfilled rate (see + // figli_compta_ledger_update_8005()) is often a blended, non-standard + // percentage that wouldn't match any of these options, and must stay + // editable/visible as the exact figure it is rather than being forced + // to snap to the nearest official rate. + if (isset($form['field_tva']['widget'][0]['value'])) { + $taux_officiels = [ + '0' => t('0 % (exonéré)'), + '2.1' => t('2,1 % (taux particulier)'), + '5.5' => t('5,5 % (taux réduit)'), + '10' => t('10 % (taux intermédiaire)'), + '20' => t('20 % (taux normal)'), + ]; + + $tva_field = &$form['field_tva']['widget'][0]['value']; + $current = $tva_field['#default_value'] ?? NULL; + $rate_default = 'autre'; + if ($current === NULL || $current === '') { + $rate_default = '0'; + } + else { + foreach (array_keys($taux_officiels) as $rate) { + if (abs((float) $current - (float) $rate) < 0.0001) { + $rate_default = $rate; + break; + } + } + } + + // A plain top-level element, deliberately *not* nested inside + // $form['field_tva'] -- Field API's WidgetBase::extractFormValues() + // reads the entire form_state value at that path as the field's own + // delta-indexed value array, and chokes on an unrelated sibling key + // mixed into it. + // + // Weight 6: the exact slot Montant HT (5)/Montant TTC (7) leave open + // between them in figli_compta_ledger_update_8004() -- field_tva + // itself has been moved to weight 8 (its own full-width row) so + // that this select forms a clean HT/rate/TTC three-column row + // whether or not "Autre" is currently revealing the real field_tva + // input further down. See css/ledger-form.css's grid-column rules + // for both -- the row layout there depends on this exact ordering, + // not just on matching #weight values. + $form['field_tva_rate'] = [ + '#type' => 'select', + '#title' => t('TVA'), + '#options' => $taux_officiels + ['autre' => t('Autre (préciser)')], + '#default_value' => $rate_default, + '#weight' => 6, + ]; + $tva_field['#title'] = t('Taux exact (%)'); + // Not #states: this element sits outside the field system (see the + // comment above) and, tested in this exact AJAX-loaded modal, + // #states' data-drupal-states attribute gets attached correctly but + // never actually toggles visibility here -- unclear why (a working + // #states dependency already exists a few fields up, on + // field_entree_liee, watching a real Field API select rather than + // this manually-added one), not worth chasing further when + // js/ledger-form.js already has to run custom JS on this form + // anyway for the HT/TVA -> TTC live preview. Plain JS toggle there + // instead -- see figliLedgerTvaRateToggle. + $form['#validate'][] = 'figli_compta_ledger_apply_tva_rate'; + } + $form['#attached']['library'][] = 'figli_compta_ledger/ledger_form'; $request = \Drupal::request(); @@ -163,6 +233,28 @@ function figli_compta_ledger_validate_repartition(array &$form, FormStateInterfa } } +/** + * Form #validate callback: when a standard French VAT rate was picked from + * the field_tva_rate select (added in figli_compta_ledger_form_alter()), + * copies it into field_tva's own submitted value -- that's the field + * figli_compta_ledger_node_presave() actually reads to compute Montant TTC. + * Left untouched when "Autre" is selected: the real field_tva input, + * shown in that case by js/ledger-form.js's figliLedgerTvaRateToggle, + * already carries whatever the associate typed there. + * + * Runs late enough to matter despite EntityForm::validateForm() building an + * interim entity earlier in the same validate phase (for constraint + * checking only) -- submitForm() rebuilds the entity fresh from + * $form_state's values right before save(), by which point every + * #validate callback, this one included, has already run. + */ +function figli_compta_ledger_apply_tva_rate(array &$form, FormStateInterface $form_state) { + $rate = $form_state->getValue('field_tva_rate'); + if ($rate !== NULL && $rate !== 'autre') { + $form_state->setValue(['field_tva', 0, 'value'], $rate); + } +} + /** * #ajax callback for the node form submit button: close the modal on * success, or re-render the form (with errors) in place on failure. 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 ccb8906..f6ac05c 100644 --- a/web/modules/custom/figli_compta_ledger/js/ledger-form.js +++ b/web/modules/custom/figli_compta_ledger/js/ledger-form.js @@ -25,17 +25,29 @@ once('figli-ledger-tva-calc', '[name="field_montant_ht[0][value]"]', context).forEach(function (ht) { var form = ht.closest('form'); var tva = form.querySelector('[name="field_tva[0][value]"]'); + var rate = form.querySelector('[name="field_tva_rate"]'); var ttc = form.querySelector('[name="field_montant_ttc[0][value]"]'); if (!tva || !ttc) { return; } + // The rate select (when not "Autre") is the actual source of + // truth here -- figli_compta_ledger_apply_tva_rate() copies its + // value into field_tva server-side on submit, but that happens + // too late to matter for a live client-side preview. + function currentTva() { + if (rate && rate.value !== 'autre') { + return parseFloat(rate.value); + } + return parseFloat(tva.value); + } + function recompute() { var htValue = parseFloat(ht.value); if (isNaN(htValue)) { return; } - var tvaValue = parseFloat(tva.value); + var tvaValue = currentTva(); if (isNaN(tvaValue)) { tvaValue = 0; } @@ -48,12 +60,48 @@ // the centime (see figli_compta_ledger_update_8005()'s // docblock) -- showing a recomputed number the instant the // modal opens, before the associate has touched anything, would - // misrepresent what's actually saved. Only recompute once HT or - // TVA are actually edited, matching the same "only overwrite - // Montant TTC when HT/TVA actually changed" guard in - // figli_compta_ledger_node_presave(). + // misrepresent what's actually saved. Only recompute once HT, + // TVA, or the rate select are actually edited, matching the + // same "only overwrite Montant TTC when HT/TVA actually + // changed" guard in figli_compta_ledger_node_presave(). Harmless + // no-op on a genuinely new/blank line either way (HT is empty, + // recompute() bails out above). ht.addEventListener('input', recompute); tva.addEventListener('input', recompute); + if (rate) { + rate.addEventListener('change', recompute); + } + }); + } + }; + + /** + * Shows/hides the real "Taux exact (%)" field_tva input based on the + * field_tva_rate select added in figli_compta_ledger_form_alter() -- + * plain JS rather than #states: tested and confirmed #states' own + * data-drupal-states attribute gets attached correctly here but never + * actually toggles visibility, for reasons not tracked down (a + * working #states dependency already exists a few fields up, on + * field_entree_liee, watching a real Field API element rather than + * this manually-added select -- that one difference is the only lead + * so far). Not worth blocking on since this form already needs custom + * JS anyway for the TTC live preview above. + */ + Drupal.behaviors.figliLedgerTvaRateToggle = { + attach: function (context) { + once('figli-ledger-tva-rate-toggle', '[name="field_tva_rate"]', context).forEach(function (select) { + var form = select.closest('form'); + var exact = form.querySelector('.field--name-field-tva'); + if (!exact) { + return; + } + + function toggle() { + exact.style.display = select.value === 'autre' ? '' : 'none'; + } + + select.addEventListener('change', toggle); + toggle(); }); } };