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 4b28a7a..29fe015 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 @@ -5,6 +5,7 @@ dependencies: config: - field.field.node.ligne_comptable.field_client - field.field.node.ligne_comptable.field_date_ligne + - field.field.node.ligne_comptable.field_entree_liee - field.field.node.ligne_comptable.field_montant_ht - field.field.node.ligne_comptable.field_montant_ttc - field.field.node.ligne_comptable.field_notes @@ -42,6 +43,16 @@ content: region: content settings: { } third_party_settings: { } + field_entree_liee: + type: entity_reference_autocomplete + weight: 2 + region: content + settings: + match_operator: CONTAINS + match_limit: 10 + size: 60 + placeholder: '' + third_party_settings: { } field_montant_ht: type: number weight: 3 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 5fe59ea..2a238d3 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 @@ -5,6 +5,7 @@ dependencies: config: - field.field.node.ligne_comptable.field_client - field.field.node.ligne_comptable.field_date_ligne + - field.field.node.ligne_comptable.field_entree_liee - field.field.node.ligne_comptable.field_montant_ht - field.field.node.ligne_comptable.field_montant_ttc - field.field.node.ligne_comptable.field_notes @@ -38,6 +39,14 @@ content: third_party_settings: { } weight: 0 region: content + field_entree_liee: + type: entity_reference_label + label: above + settings: + link: true + third_party_settings: { } + weight: 2 + region: content field_montant_ht: type: number_decimal label: above diff --git a/config/sync/field.field.node.ligne_comptable.field_entree_liee.yml b/config/sync/field.field.node.ligne_comptable.field_entree_liee.yml new file mode 100644 index 0000000..303ad8e --- /dev/null +++ b/config/sync/field.field.node.ligne_comptable.field_entree_liee.yml @@ -0,0 +1,23 @@ +uuid: c2d68287-8e24-4529-a035-39395acca4d3 +langcode: en +status: true +dependencies: + config: + - field.storage.node.field_entree_liee + - node.type.ligne_comptable +id: node.ligne_comptable.field_entree_liee +field_name: field_entree_liee +entity_type: node +bundle: ligne_comptable +label: 'Entrée client liée' +description: '' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: + handler: 'figli_compta_ledger:entree_client' + handler_settings: + target_bundles: + ligne_comptable: ligne_comptable +field_type: entity_reference diff --git a/config/sync/field.storage.node.field_entree_liee.yml b/config/sync/field.storage.node.field_entree_liee.yml new file mode 100644 index 0000000..7e4e94d --- /dev/null +++ b/config/sync/field.storage.node.field_entree_liee.yml @@ -0,0 +1,19 @@ +uuid: d7148451-7a96-4515-b0b5-42ad9fecbdce +langcode: en +status: true +dependencies: + module: + - node +id: node.field_entree_liee +field_name: field_entree_liee +entity_type: node +type: entity_reference +settings: + target_type: node +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/web/modules/custom/figli_compta_ledger/config/schema/figli_compta_ledger.schema.yml b/web/modules/custom/figli_compta_ledger/config/schema/figli_compta_ledger.schema.yml new file mode 100644 index 0000000..0c3120f --- /dev/null +++ b/web/modules/custom/figli_compta_ledger/config/schema/figli_compta_ledger.schema.yml @@ -0,0 +1,5 @@ +# EntreeClientSelection only adds query conditions on top of NodeSelection +# (itself a DefaultSelection subclass) -- no new handler_settings keys, so +# it uses the same schema shape as the core 'default' handler. +entity_reference_selection.figli_compta_ledger: + type: entity_reference_selection.default diff --git a/web/modules/custom/figli_compta_ledger/css/home.css b/web/modules/custom/figli_compta_ledger/css/home.css index 25f3107..0b01ae1 100644 --- a/web/modules/custom/figli_compta_ledger/css/home.css +++ b/web/modules/custom/figli_compta_ledger/css/home.css @@ -204,7 +204,14 @@ html.gin--dark-mode #figli-home-app { text-align: center; } -#figli-home-app .figli-edit-btn { +#figli-home-app td.actions-col { + display: flex; + gap: 0.15rem; + justify-content: center; +} + +#figli-home-app .figli-edit-btn, +#figli-home-app .figli-link-btn { display: inline-flex; align-items: center; justify-content: center; @@ -218,12 +225,45 @@ html.gin--dark-mode #figli-home-app { cursor: pointer; } -#figli-home-app .figli-edit-btn:hover { +#figli-home-app .figli-edit-btn:hover, +#figli-home-app .figli-link-btn:hover { background: var(--figli-bg-alt); border-color: var(--figli-border); color: var(--figli-text); } +#figli-home-app .figli-link-btn.is-linked { + color: #3b6fe0; +} + +/* Reconciliation badge on entrée rows: répartition (money owed) vs the + combined répartition of every linked sortie (money actually paid out). */ +#figli-home-app .figli-recon-badge { + display: inline-block; + margin-left: 0.4rem; + padding: 0.05rem 0.4rem; + border-radius: 10px; + font-size: 0.68rem; + font-weight: 600; + white-space: nowrap; + cursor: pointer; + background: #1a7f371a; + color: var(--figli-positive); +} +#figli-home-app .figli-recon-badge.is-reste { + background: #d97a0a1a; + color: #d97a0a; +} +#figli-home-app .figli-recon-badge.is-anomalie { + background: #c9312b1a; + color: var(--figli-error); +} + +#figli-home-app .figli-clear-drilldown { + font-size: 0.8rem; + padding: 0.2rem 0.6rem; +} + #figli-home-app .figli-error { background: #fde8e8; border: 1px solid #f4a3a3; 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 00945f8..1b0fc7f 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module @@ -51,6 +51,21 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state, $form['revision_log']['#access'] = FALSE; } + // field_entree_liee only makes sense on the "sortie" types that pay out + // against a client invoice -- charge/autre/ouverture aren't tied to a + // specific entrée, and entree lines don't link to themselves. + if (isset($form['field_entree_liee'])) { + $form['field_entree_liee']['#states'] = [ + 'visible' => [ + [':input[name="field_type_ligne"]' => ['value' => 'versement']], + 'or', + [':input[name="field_type_ligne"]' => ['value' => 'achat']], + 'or', + [':input[name="field_type_ligne"]' => ['value' => 'hebergement']], + ], + ]; + } + $request = \Drupal::request(); $wrapper_formats = ['drupal_ajax', 'drupal_modal', 'drupal_dialog']; $is_ajax_modal = in_array($request->query->get('_wrapper_format'), $wrapper_formats, TRUE) diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml index 9ddd8fc..72bd70a 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml @@ -21,3 +21,15 @@ figli_compta_ledger.history: _title: 'Historique du grand livre - SAS Figures Libres' requirements: _permission: 'view ligne_comptable revisions' + +figli_compta_ledger.link_entree: + path: '/lignes/{node}/lier' + defaults: + _form: '\Drupal\figli_compta_ledger\Form\LinkEntreeForm' + _title: 'Lier à une entrée client' + requirements: + _entity_access: 'node.update' + options: + parameters: + node: + type: entity:node diff --git a/web/modules/custom/figli_compta_ledger/js/home.js b/web/modules/custom/figli_compta_ledger/js/home.js index 0d08e8a..3737785 100644 --- a/web/modules/custom/figli_compta_ledger/js/home.js +++ b/web/modules/custom/figli_compta_ledger/js/home.js @@ -21,13 +21,16 @@ autre: 'Autre', ouverture: 'Ouverture', }; + // Sorties that can be linked to the entrée client they pay out against + // (field_entree_liee) -- charge/autre/ouverture aren't client-specific. + const LINKABLE_TYPES = ['versement', 'achat', 'hebergement']; async function fetchAllLignes() { // sort includes drupal_internal__nid as a tie-breaker: field_date_ligne // alone is not unique (many lines share a date), and without a unique // secondary sort key, offset pagination can silently duplicate or skip // rows across pages. - let url = API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client&page[limit]=50&sort=field_date_ligne,drupal_internal__nid'; + let url = API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client,field_entree_liee&page[limit]=50&sort=field_date_ligne,drupal_internal__nid'; const allData = []; const includedMap = new Map(); while (url) { @@ -55,6 +58,7 @@ const rels = node.relationships || {}; const attrs = node.attributes; const clientTerm = resolve(includedMap, rels.field_client && rels.field_client.data); + const entreeLieeNode = resolve(includedMap, rels.field_entree_liee && rels.field_entree_liee.data); const parCompte = {}; let somme = 0; const repartitionRefs = (rels.field_repartition && rels.field_repartition.data) || []; @@ -82,6 +86,9 @@ somme, ecart, hasError: Math.abs(ecart) > 0.01, + linkable: LINKABLE_TYPES.includes(attrs.field_type_ligne), + entreeLieeId: entreeLieeNode ? entreeLieeNode.id : null, + entreeLieeLabel: entreeLieeNode ? (entreeLieeNode.attributes.title || null) : null, }); } rows.sort((a, b) => (a.date || '').localeCompare(b.date || '')); @@ -102,6 +109,7 @@ groupBy: 'month', onlyErrors: false, hoverCol: null, + filterEntreeId: null, }; }, computed: { @@ -117,6 +125,53 @@ errorCount() { return this.rows.filter((r) => r.hasError).length; }, + sortiesByEntree() { + const map = new Map(); + for (const r of this.rows) { + if (!r.entreeLieeId) continue; + if (!map.has(r.entreeLieeId)) map.set(r.entreeLieeId, []); + map.get(r.entreeLieeId).push(r); + } + return map; + }, + // Compares each entrée's répartition (positive shares owed) against + // the combined répartition of every sortie linked to it (negative + // amounts paid out), per compte -- both sides can be split across + // several comptes. A residual near zero means fully settled; + // positive means still owed ("reste à verser"); negative means more + // was paid out than the entrée allocated ("sur-versé", worth a + // closer look). Precomputed once for all entrée rows rather than + // recomputed per template read. + reconciliationByEntree() { + const map = new Map(); + for (const entreeRow of this.rows) { + if (entreeRow.type !== 'entree') continue; + const linked = this.sortiesByEntree.get(entreeRow.id) || []; + const versementsParCompte = {}; + for (const s of linked) { + for (const [compte, montant] of Object.entries(s.parCompte)) { + versementsParCompte[compte] = (versementsParCompte[compte] || 0) + montant; + } + } + const comptes = new Set([...Object.keys(entreeRow.parCompte), ...Object.keys(versementsParCompte)]); + let resteAVerser = 0; + let surVerse = 0; + const detail = []; + for (const c of comptes) { + const residual = Math.round(((entreeRow.parCompte[c] || 0) + (versementsParCompte[c] || 0)) * 100) / 100; + if (residual > 0.01) resteAVerser += residual; + else if (residual < -0.01) surVerse += -residual; + if (Math.abs(residual) > 0.01) detail.push(c + ' : ' + this.formatEur(residual)); + } + map.set(entreeRow.id, { + count: linked.length, + resteAVerser: Math.round(resteAVerser * 100) / 100, + surVerse: Math.round(surVerse * 100) / 100, + detail: detail.join(', ') || 'Entièrement soldé', + }); + } + return map; + }, footerTotals() { const parCompte = {}; this.allComptes.forEach((c) => { parCompte[c] = 0; }); @@ -132,6 +187,13 @@ return { montant_ht: montantHt, montant_ttc: montantTtc, parCompte, ecart }; }, filteredRows() { + // Drill-down mode: an entrée and only the sorties linked to it, + // ignoring the other filters -- clicking its badge again clears it. + if (this.filterEntreeId) { + const entree = this.rows.find((r) => r.id === this.filterEntreeId); + const linked = this.sortiesByEntree.get(this.filterEntreeId) || []; + return entree ? [entree, ...linked] : linked; + } return this.rows.filter((r) => { if (this.filterCompte && r.parCompte[this.filterCompte] === undefined) return false; if (this.filterClient && r.client !== this.filterClient) return false; @@ -196,6 +258,17 @@ progress: { type: 'throbber' }, }).execute(); }, + openLinkForm(nid) { + Drupal.ajax({ + url: '/lignes/' + nid + '/lier', + dialogType: 'modal', + dialog: { width: 500, title: 'Lier à une entrée client' }, + progress: { type: 'throbber' }, + }).execute(); + }, + toggleEntreeFilter(id) { + this.filterEntreeId = this.filterEntreeId === id ? null : id; + }, onCellHover(evt) { const cell = evt.target.closest('td, th'); if (!cell) return; diff --git a/web/modules/custom/figli_compta_ledger/src/Form/LinkEntreeForm.php b/web/modules/custom/figli_compta_ledger/src/Form/LinkEntreeForm.php new file mode 100644 index 0000000..2b31967 --- /dev/null +++ b/web/modules/custom/figli_compta_ledger/src/Form/LinkEntreeForm.php @@ -0,0 +1,85 @@ +set('node', $node); + + $form['field_entree_liee'] = [ + '#type' => 'entity_autocomplete', + '#title' => $this->t('Entrée client liée'), + '#target_type' => 'node', + '#selection_handler' => 'figli_compta_ledger:entree_client', + '#selection_settings' => [ + 'target_bundles' => ['ligne_comptable' => 'ligne_comptable'], + // Narrows the autocomplete to the same client as this sortie -- + // read by EntreeClientSelection::buildEntityQuery(). + 'entity' => $node, + ], + '#default_value' => $node->get('field_entree_liee')->entity, + '#description' => $this->t('Laisser vide pour retirer le lien.'), + ]; + + $form['actions'] = ['#type' => 'actions']; + $form['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Enregistrer'), + '#ajax' => [ + 'callback' => '::ajaxSubmit', + ], + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + /** @var \Drupal\node\NodeInterface $node */ + $node = $form_state->get('node'); + $node->set('field_entree_liee', $form_state->getValue('field_entree_liee') ?: NULL); + $node->save(); + } + + /** + * #ajax callback: close the modal on success (the /lignes table picks up + * "dialog:afterclose" to refresh), or re-render the form in place. + */ + public function ajaxSubmit(array &$form, FormStateInterface $form_state) { + $response = new AjaxResponse(); + if ($form_state->getErrors()) { + unset($form['#prefix'], $form['#suffix']); + $response->addCommand(new ReplaceCommand('#' . $form['#id'], $form)); + return $response; + } + $response->addCommand(new CloseModalDialogCommand()); + return $response; + } + +} diff --git a/web/modules/custom/figli_compta_ledger/src/Plugin/EntityReferenceSelection/EntreeClientSelection.php b/web/modules/custom/figli_compta_ledger/src/Plugin/EntityReferenceSelection/EntreeClientSelection.php new file mode 100644 index 0000000..88e773f --- /dev/null +++ b/web/modules/custom/figli_compta_ledger/src/Plugin/EntityReferenceSelection/EntreeClientSelection.php @@ -0,0 +1,38 @@ +condition('field_type_ligne', 'entree'); + + $entity = $this->configuration['entity'] ?? NULL; + if ($entity && $entity->hasField('field_client') && !$entity->get('field_client')->isEmpty()) { + $query->condition('field_client', $entity->get('field_client')->target_id); + } + + return $query; + } + +} diff --git a/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig b/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig index df10fd3..a515a6e 100644 --- a/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig +++ b/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig @@ -55,6 +55,8 @@ Écarts uniquement + + {{ filteredRows.length }} / {{ rows.length }} lignes{{ errorCount ? ' — ' + errorCount + ' avec écart' : '' }} @@ -84,12 +86,34 @@ {{ item.date }} {{ item.client || '—' }} {{ typeLabel(item.type) }} - {{ item.libelle }} + + {{ item.libelle }} + {{ reconciliationByEntree.get(item.id).count }} sortie{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }} liée{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }} + {{ formatEur(item.montant_ht) }} {{ formatEur(item.montant_ttc) }} {{ item.parCompte[c] !== undefined ? formatEur(item.parCompte[c]) : '' }} {{ item.hasError ? formatEur(item.ecart) : '' }} +