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 @@