diff --git a/config/sync/field.storage.node.field_entree_liee.yml b/config/sync/field.storage.node.field_entree_liee.yml index 7e4e94d..81e8dc8 100644 --- a/config/sync/field.storage.node.field_entree_liee.yml +++ b/config/sync/field.storage.node.field_entree_liee.yml @@ -12,7 +12,7 @@ settings: target_type: node module: core locked: false -cardinality: 1 +cardinality: -1 translatable: true indexes: { } persist_with_no_fields: false diff --git a/web/modules/custom/figli_compta_ledger/css/home.css b/web/modules/custom/figli_compta_ledger/css/home.css index 13e080c..9c7bd68 100644 --- a/web/modules/custom/figli_compta_ledger/css/home.css +++ b/web/modules/custom/figli_compta_ledger/css/home.css @@ -58,6 +58,16 @@ html.gin--dark-mode #figli-home-app { border-radius: 4px; } +#figli-home-app .figli-client-input { + font-size: 0.85rem; + padding: 0.2rem 0.4rem; + background: var(--figli-bg); + color: var(--figli-text); + border: 1px solid var(--figli-border); + border-radius: 4px; + width: 12rem; +} + #figli-home-app .figli-checkbox { flex-direction: row !important; align-items: center; @@ -299,10 +309,15 @@ html.gin--dark-mode #figli-home-app { font-size: 0.68rem; font-weight: 600; white-space: nowrap; - cursor: pointer; background: #1a7f371a; color: var(--figli-positive); } +/* Not every badge using this class does something on click (the + ouverture/clôture écart badge is purely informational) -- only show + the pointer cursor where a click actually goes somewhere. */ +#figli-home-app .figli-recon-badge.is-clickable { + cursor: pointer; +} #figli-home-app .figli-recon-badge.is-reste { background: #d97a0a1a; color: #d97a0a; diff --git a/web/modules/custom/figli_compta_ledger/js/home.js b/web/modules/custom/figli_compta_ledger/js/home.js index 41beb85..2665b99 100644 --- a/web/modules/custom/figli_compta_ledger/js/home.js +++ b/web/modules/custom/figli_compta_ledger/js/home.js @@ -215,7 +215,12 @@ 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); + // field_entree_liee is multi-value (a single payment sometimes + // covers several client invoices at once) -- JSON:API always + // returns an array for a multi-cardinality relationship, one ref + // per linked entrée, even when there's only one or none. + const entreeLieeRefs = (rels.field_entree_liee && rels.field_entree_liee.data) || []; + const entreeLieeNodes = entreeLieeRefs.map((ref) => resolve(includedMap, ref)).filter(Boolean); const parCompte = {}; let somme = 0; const repartitionRefs = (rels.field_repartition && rels.field_repartition.data) || []; @@ -244,8 +249,8 @@ 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, + entreeLieeIds: entreeLieeNodes.map((n) => n.id), + entreeLieeLabels: entreeLieeNodes.map((n) => n.attributes.title || n.id), }); } rows.sort((a, b) => (a.date || '').localeCompare(b.date || '')); @@ -308,12 +313,15 @@ errorCount() { return this.rows.filter((r) => r.hasError).length; }, + // A sortie linked to several entrées (one payment covering several + // invoices) appears under each of them here. 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); + for (const entreeId of r.entreeLieeIds) { + if (!map.has(entreeId)) map.set(entreeId, []); + map.get(entreeId).push(r); + } } return map; }, @@ -327,6 +335,14 @@ // recomputed per template read. Limited to the currently loaded // window -- a sortie linked to an entrée outside it won't be // counted (accepted trade-off of the sliding window). + // + // A sortie linked to several entrées at once (one payment covering + // several invoices) has no record of how much of it applies to + // each -- there's no per-link amount, just a set of linked entrées. + // Split its répartition equally between them as the least-wrong + // assumption available, rather than counting its full amount + // against every linked entrée (which would double- or triple-count + // the same money). reconciliationByEntree() { const map = new Map(); for (const entreeRow of this.rows) { @@ -334,8 +350,9 @@ const linked = this.sortiesByEntree.get(entreeRow.id) || []; const versementsParCompte = {}; for (const s of linked) { + const share = s.entreeLieeIds.length || 1; for (const [compte, montant] of Object.entries(s.parCompte)) { - versementsParCompte[compte] = (versementsParCompte[compte] || 0) + montant; + versementsParCompte[compte] = (versementsParCompte[compte] || 0) + montant / share; } } const comptes = new Set([...Object.keys(entreeRow.parCompte), ...Object.keys(versementsParCompte)]); @@ -365,13 +382,37 @@ } return map; }, + // The full connected group of entrées + sorties reachable from + // filterEntreeId by following field_entree_liee links transitively. + // A sortie can now link to several entrées at once (split payment), + // so drilling into one entrée should also surface every *other* + // entrée it shares a sortie with, and that entrée's own sorties in + // turn -- not just the originally-clicked entrée's direct links. + filterEntreeGroup() { + if (!this.filterEntreeId) return null; + const entreeIds = new Set([this.filterEntreeId]); + let grown = true; + while (grown) { + grown = false; + for (const r of this.rows) { + if (r.type === 'entree' || !r.entreeLieeIds.some((id) => entreeIds.has(id))) continue; + for (const id of r.entreeLieeIds) { + if (!entreeIds.has(id)) { + entreeIds.add(id); + grown = true; + } + } + } + } + const entrees = this.rows.filter((r) => r.type === 'entree' && entreeIds.has(r.id)); + const sorties = this.rows.filter((r) => r.type !== 'entree' && r.entreeLieeIds.some((id) => entreeIds.has(id))); + return [...entrees, ...sorties]; + }, filteredRows() { - // Drill-down mode: an entrée and only the sorties linked to it, - // ignoring the other filters -- clicking its badge again clears it. + // Drill-down mode: the connected entrée/sortie group, ignoring + // the other filters -- clicking the 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.filterEntreeGroup; } return this.rows.filter((r) => { if (this.filterCompte && r.parCompte[this.filterCompte] === undefined) return false; @@ -468,31 +509,40 @@ return { detail, comptes: relevant.length }; }, // Flags a "versement freelance" row that isn't (fully) backed by - // the entrée client it pays out against: either not linked at all, - // or linked but its own compte(s) still show a residual against - // that entrée. Deliberately scoped to just the compte(s) this - // versement's own répartition touches (reconciliationByEntree's - // parCompteResidual), not the entrée's overall resteAVerser/ - // surVerse -- those can be driven entirely by a *different* compte - // tied to some other sortie linked to the same entrée, which says - // nothing about whether this versement's own répartition is - // settled. Null (no highlight) when the linked entrée isn't in the - // currently loaded window -- same accepted trade-off as - // reconciliationByEntree itself. + // the entrée client(s) it pays out against: either not linked at + // all, or linked but its own compte(s) still show a residual + // against at least one of them. Deliberately scoped to just the + // compte(s) this versement's own répartition touches + // (reconciliationByEntree's parCompteResidual), not the entrée's + // overall resteAVerser/surVerse -- those can be driven entirely by + // a *different* compte tied to some other sortie linked to the + // same entrée, which says nothing about whether this versement's + // own répartition is settled. When linked to several entrées (see + // reconciliationByEntree's equal-split note), each entrée's + // residual for these compte(s) counts separately -- they're + // independent invoices, each with its own outstanding amount. + // Entrées outside the currently loaded window are skipped (same + // accepted trade-off as reconciliationByEntree itself); null only + // if none of them could be checked at all. versementStatus(item) { if (item.type !== 'versement') return null; - if (!item.entreeLieeId) { + if (!item.entreeLieeIds.length) { return { kind: 'non-liee', detail: 'Aucune entrée client liée.' }; } - const recon = this.reconciliationByEntree.get(item.entreeLieeId); - if (!recon) return null; let resteAVerser = 0; let surVerse = 0; - for (const c of Object.keys(item.parCompte)) { - const residual = recon.parCompteResidual[c] || 0; - if (residual > 0.01) resteAVerser += residual; - else if (residual < -0.01) surVerse += -residual; + let checked = 0; + for (const entreeId of item.entreeLieeIds) { + const recon = this.reconciliationByEntree.get(entreeId); + if (!recon) continue; + checked++; + for (const c of Object.keys(item.parCompte)) { + const residual = recon.parCompteResidual[c] || 0; + if (residual > 0.01) resteAVerser += residual; + else if (residual < -0.01) surVerse += -residual; + } } + if (!checked) return null; if (resteAVerser > 0.01) { return { kind: 'reste', detail: 'Reste à verser (comptes de cette ligne) : ' + this.formatEur(Math.round(resteAVerser * 100) / 100) }; } @@ -579,8 +629,8 @@ row.type = newType; row.linkable = LINKABLE_TYPES.includes(newType); if (result.entree_liee_cleared) { - row.entreeLieeId = null; - row.entreeLieeLabel = null; + row.entreeLieeIds = []; + row.entreeLieeLabels = []; } } this.reloadWindow(); diff --git a/web/modules/custom/figli_compta_ledger/src/Form/LinkEntreeForm.php b/web/modules/custom/figli_compta_ledger/src/Form/LinkEntreeForm.php index 2b31967..4d73ff0 100644 --- a/web/modules/custom/figli_compta_ledger/src/Form/LinkEntreeForm.php +++ b/web/modules/custom/figli_compta_ledger/src/Form/LinkEntreeForm.php @@ -13,7 +13,11 @@ use Drupal\node\NodeInterface; * Quick-link form: sets field_entree_liee on a single sortie line without * opening the full ligne_comptable edit form -- the associates only ever * need to touch this one field to link a versement/achat/hébergement to - * the entrée client it pays out against. + * the entrée client(s) it pays out against. field_entree_liee is + * multi-value (cardinality unlimited) since one payment sometimes covers + * several client invoices at once; #tags renders that as a single + * comma-separated autocomplete field instead of a Drupal "add another + * item" widget. */ class LinkEntreeForm extends FormBase { @@ -32,8 +36,9 @@ class LinkEntreeForm extends FormBase { $form['field_entree_liee'] = [ '#type' => 'entity_autocomplete', - '#title' => $this->t('Entrée client liée'), + '#title' => $this->t('Entrées clients liées'), '#target_type' => 'node', + '#tags' => TRUE, '#selection_handler' => 'figli_compta_ledger:entree_client', '#selection_settings' => [ 'target_bundles' => ['ligne_comptable' => 'ligne_comptable'], @@ -41,8 +46,8 @@ class LinkEntreeForm extends FormBase { // read by EntreeClientSelection::buildEntityQuery(). 'entity' => $node, ], - '#default_value' => $node->get('field_entree_liee')->entity, - '#description' => $this->t('Laisser vide pour retirer le lien.'), + '#default_value' => $node->get('field_entree_liee')->referencedEntities(), + '#description' => $this->t('Laisser vide pour retirer tous les liens. Plusieurs entrées possibles (paiement en plusieurs fois) : séparez-les par une virgule.'), ]; $form['actions'] = ['#type' => 'actions']; @@ -63,7 +68,12 @@ class LinkEntreeForm extends FormBase { 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); + // #tags => TRUE normalizes the submitted value to the field API's own + // multi-value shape (an array of ['target_id' => ..., ...] items, one + // per comma-separated entry), so this is just field-API assignment, + // no manual tag-string parsing needed. + $values = $form_state->getValue('field_entree_liee') ?: []; + $node->set('field_entree_liee', $values); $node->save(); } 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 5a3b034..1f15c6c 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 @@ -27,10 +27,10 @@