Client autocomplete, drill down from versements, multi-entrée linking

Three related changes to the /lignes table:

1. The "Client" filter is now a text input with a <datalist> instead
   of a <select> -- 50+ clients made the dropdown unwieldy. v-model.lazy
   (not the default per-keystroke binding) since a change here triggers
   ensureScrollable() and a hash rewrite, which shouldn't fire on every
   character typed.

2. Clicking a "versement freelance" row's own status badge (Non liée /
   Reste à verser / Sur-versé) now drills down the same way an entrée's
   "N sorties liées" badge already did, instead of only being clickable
   from the entrée side.

3. field_entree_liee is now multi-value (cardinality unlimited) --
   sometimes one payment covers several client invoices at once.
   LinkEntreeForm uses #tags => TRUE (a single comma-separated
   autocomplete field, Drupal's field-API-native multi-value shape on
   submit, no manual tag parsing needed). This is the deeper change and
   touches most of the reconciliation logic in home.js:
   - buildRows() reads field_entree_liee as an array
     (entreeLieeIds/entreeLieeLabels) -- JSON:API always returns a list
     for a multi-cardinality relationship now, even with 0 or 1 items.
   - sortiesByEntree indexes a sortie under every entrée it links to.
   - reconciliationByEntree splits a multi-linked sortie's répartition
     equally across its linked entrées -- there's no per-link amount to
     divide by, so equal split is the least-wrong assumption available
     rather than counting the sortie's full amount against every linked
     entrée (which would double-count the same money).
   - versementStatus() sums residuals across all of a versement's linked
     entrées for its own compte(s), skipping any not in the currently
     loaded window (same accepted trade-off reconciliationByEntree
     already had).
   - The drill-down (filterEntreeId) is now filterEntreeGroup, a
     transitive closure over shared entrée<->sortie links -- clicking
     one entrée (or, per #2, one versement) surfaces every other entrée
     it's connected to through a shared sortie, and every sortie linked
     to any of them, not just the originally-clicked one's direct links.

Verified end-to-end: linked a real unlinked versement to two entrées
for the same client via the actual form submission (no manual DB
edit), confirmed both persisted, confirmed the link button's tooltip
lists both, and confirmed clicking either the versement's or an
entrée's badge produces the same 3-row connected group with correct
drill-down footer totals. Reverted the test link afterward.
This commit is contained in:
2026-09-05 11:35:18 +02:00
parent 0170ec4475
commit 0314625593
5 changed files with 124 additions and 48 deletions
@@ -12,7 +12,7 @@ settings:
target_type: node target_type: node
module: core module: core
locked: false locked: false
cardinality: 1 cardinality: -1
translatable: true translatable: true
indexes: { } indexes: { }
persist_with_no_fields: false persist_with_no_fields: false
@@ -58,6 +58,16 @@ html.gin--dark-mode #figli-home-app {
border-radius: 4px; 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 { #figli-home-app .figli-checkbox {
flex-direction: row !important; flex-direction: row !important;
align-items: center; align-items: center;
@@ -299,10 +309,15 @@ html.gin--dark-mode #figli-home-app {
font-size: 0.68rem; font-size: 0.68rem;
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
cursor: pointer;
background: #1a7f371a; background: #1a7f371a;
color: var(--figli-positive); 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 { #figli-home-app .figli-recon-badge.is-reste {
background: #d97a0a1a; background: #d97a0a1a;
color: #d97a0a; color: #d97a0a;
@@ -215,7 +215,12 @@
const rels = node.relationships || {}; const rels = node.relationships || {};
const attrs = node.attributes; const attrs = node.attributes;
const clientTerm = resolve(includedMap, rels.field_client && rels.field_client.data); 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 = {}; const parCompte = {};
let somme = 0; let somme = 0;
const repartitionRefs = (rels.field_repartition && rels.field_repartition.data) || []; const repartitionRefs = (rels.field_repartition && rels.field_repartition.data) || [];
@@ -244,8 +249,8 @@
ecart, ecart,
hasError: Math.abs(ecart) > 0.01, hasError: Math.abs(ecart) > 0.01,
linkable: LINKABLE_TYPES.includes(attrs.field_type_ligne), linkable: LINKABLE_TYPES.includes(attrs.field_type_ligne),
entreeLieeId: entreeLieeNode ? entreeLieeNode.id : null, entreeLieeIds: entreeLieeNodes.map((n) => n.id),
entreeLieeLabel: entreeLieeNode ? (entreeLieeNode.attributes.title || null) : null, entreeLieeLabels: entreeLieeNodes.map((n) => n.attributes.title || n.id),
}); });
} }
rows.sort((a, b) => (a.date || '').localeCompare(b.date || '')); rows.sort((a, b) => (a.date || '').localeCompare(b.date || ''));
@@ -308,12 +313,15 @@
errorCount() { errorCount() {
return this.rows.filter((r) => r.hasError).length; 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() { sortiesByEntree() {
const map = new Map(); const map = new Map();
for (const r of this.rows) { for (const r of this.rows) {
if (!r.entreeLieeId) continue; for (const entreeId of r.entreeLieeIds) {
if (!map.has(r.entreeLieeId)) map.set(r.entreeLieeId, []); if (!map.has(entreeId)) map.set(entreeId, []);
map.get(r.entreeLieeId).push(r); map.get(entreeId).push(r);
}
} }
return map; return map;
}, },
@@ -327,6 +335,14 @@
// recomputed per template read. Limited to the currently loaded // recomputed per template read. Limited to the currently loaded
// window -- a sortie linked to an entrée outside it won't be // window -- a sortie linked to an entrée outside it won't be
// counted (accepted trade-off of the sliding window). // 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() { reconciliationByEntree() {
const map = new Map(); const map = new Map();
for (const entreeRow of this.rows) { for (const entreeRow of this.rows) {
@@ -334,8 +350,9 @@
const linked = this.sortiesByEntree.get(entreeRow.id) || []; const linked = this.sortiesByEntree.get(entreeRow.id) || [];
const versementsParCompte = {}; const versementsParCompte = {};
for (const s of linked) { for (const s of linked) {
const share = s.entreeLieeIds.length || 1;
for (const [compte, montant] of Object.entries(s.parCompte)) { 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)]); const comptes = new Set([...Object.keys(entreeRow.parCompte), ...Object.keys(versementsParCompte)]);
@@ -365,13 +382,37 @@
} }
return map; 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() { filteredRows() {
// Drill-down mode: an entrée and only the sorties linked to it, // Drill-down mode: the connected entrée/sortie group, ignoring
// ignoring the other filters -- clicking its badge again clears it. // the other filters -- clicking the badge again clears it.
if (this.filterEntreeId) { if (this.filterEntreeId) {
const entree = this.rows.find((r) => r.id === this.filterEntreeId); return this.filterEntreeGroup;
const linked = this.sortiesByEntree.get(this.filterEntreeId) || [];
return entree ? [entree, ...linked] : linked;
} }
return this.rows.filter((r) => { return this.rows.filter((r) => {
if (this.filterCompte && r.parCompte[this.filterCompte] === undefined) return false; if (this.filterCompte && r.parCompte[this.filterCompte] === undefined) return false;
@@ -468,31 +509,40 @@
return { detail, comptes: relevant.length }; return { detail, comptes: relevant.length };
}, },
// Flags a "versement freelance" row that isn't (fully) backed by // Flags a "versement freelance" row that isn't (fully) backed by
// the entrée client it pays out against: either not linked at all, // the entrée client(s) it pays out against: either not linked at
// or linked but its own compte(s) still show a residual against // all, or linked but its own compte(s) still show a residual
// that entrée. Deliberately scoped to just the compte(s) this // against at least one of them. Deliberately scoped to just the
// versement's own répartition touches (reconciliationByEntree's // compte(s) this versement's own répartition touches
// parCompteResidual), not the entrée's overall resteAVerser/ // (reconciliationByEntree's parCompteResidual), not the entrée's
// surVerse -- those can be driven entirely by a *different* compte // overall resteAVerser/surVerse -- those can be driven entirely by
// tied to some other sortie linked to the same entrée, which says // a *different* compte tied to some other sortie linked to the
// nothing about whether this versement's own répartition is // same entrée, which says nothing about whether this versement's
// settled. Null (no highlight) when the linked entrée isn't in the // own répartition is settled. When linked to several entrées (see
// currently loaded window -- same accepted trade-off as // reconciliationByEntree's equal-split note), each entrée's
// reconciliationByEntree itself. // 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) { versementStatus(item) {
if (item.type !== 'versement') return null; if (item.type !== 'versement') return null;
if (!item.entreeLieeId) { if (!item.entreeLieeIds.length) {
return { kind: 'non-liee', detail: 'Aucune entrée client liée.' }; 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 resteAVerser = 0;
let surVerse = 0; let surVerse = 0;
for (const c of Object.keys(item.parCompte)) { let checked = 0;
const residual = recon.parCompteResidual[c] || 0; for (const entreeId of item.entreeLieeIds) {
if (residual > 0.01) resteAVerser += residual; const recon = this.reconciliationByEntree.get(entreeId);
else if (residual < -0.01) surVerse += -residual; 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) { if (resteAVerser > 0.01) {
return { kind: 'reste', detail: 'Reste à verser (comptes de cette ligne) : ' + this.formatEur(Math.round(resteAVerser * 100) / 100) }; 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.type = newType;
row.linkable = LINKABLE_TYPES.includes(newType); row.linkable = LINKABLE_TYPES.includes(newType);
if (result.entree_liee_cleared) { if (result.entree_liee_cleared) {
row.entreeLieeId = null; row.entreeLieeIds = [];
row.entreeLieeLabel = null; row.entreeLieeLabels = [];
} }
} }
this.reloadWindow(); this.reloadWindow();
@@ -13,7 +13,11 @@ use Drupal\node\NodeInterface;
* Quick-link form: sets field_entree_liee on a single sortie line without * Quick-link form: sets field_entree_liee on a single sortie line without
* opening the full ligne_comptable edit form -- the associates only ever * 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 * 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 { class LinkEntreeForm extends FormBase {
@@ -32,8 +36,9 @@ class LinkEntreeForm extends FormBase {
$form['field_entree_liee'] = [ $form['field_entree_liee'] = [
'#type' => 'entity_autocomplete', '#type' => 'entity_autocomplete',
'#title' => $this->t('Entrée client liée'), '#title' => $this->t('Entrées clients liées'),
'#target_type' => 'node', '#target_type' => 'node',
'#tags' => TRUE,
'#selection_handler' => 'figli_compta_ledger:entree_client', '#selection_handler' => 'figli_compta_ledger:entree_client',
'#selection_settings' => [ '#selection_settings' => [
'target_bundles' => ['ligne_comptable' => 'ligne_comptable'], 'target_bundles' => ['ligne_comptable' => 'ligne_comptable'],
@@ -41,8 +46,8 @@ class LinkEntreeForm extends FormBase {
// read by EntreeClientSelection::buildEntityQuery(). // read by EntreeClientSelection::buildEntityQuery().
'entity' => $node, 'entity' => $node,
], ],
'#default_value' => $node->get('field_entree_liee')->entity, '#default_value' => $node->get('field_entree_liee')->referencedEntities(),
'#description' => $this->t('Laisser vide pour retirer le lien.'), '#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']; $form['actions'] = ['#type' => 'actions'];
@@ -63,7 +68,12 @@ class LinkEntreeForm extends FormBase {
public function submitForm(array &$form, FormStateInterface $form_state) { public function submitForm(array &$form, FormStateInterface $form_state) {
/** @var \Drupal\node\NodeInterface $node */ /** @var \Drupal\node\NodeInterface $node */
$node = $form_state->get('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(); $node->save();
} }
@@ -27,10 +27,10 @@
</label> </label>
<label>Client <label>Client
<select v-model="filterClient"> <input type="text" v-model.lazy="filterClient" list="figli-client-datalist" placeholder="Tous" autocomplete="off" class="figli-client-input" />
<option value="">Tous</option> <datalist id="figli-client-datalist">
<option v-for="c in allClientsList" :key="c" :value="c">{{ c }}</option> <option v-for="c in allClientsList" :key="c" :value="c"></option>
</select> </datalist>
</label> </label>
<label>Type <label>Type
@@ -107,8 +107,8 @@
v-if="item.linkable" v-if="item.linkable"
type="button" type="button"
class="figli-link-btn" class="figli-link-btn"
:class="{'is-linked': item.entreeLieeId}" :class="{'is-linked': item.entreeLieeIds.length}"
:title="item.entreeLieeId ? 'Lié à : ' + item.entreeLieeLabel : 'Lier à une entrée client'" :title="item.entreeLieeIds.length ? 'Lié à : ' + item.entreeLieeLabels.join(', ') : 'Lier à une entrée client'"
@click="openLinkForm(item.nid)" @click="openLinkForm(item.nid)"
> >
<svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"> <svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
@@ -148,7 +148,7 @@
{{ item.libelle }} {{ item.libelle }}
<span <span
v-if="item.type === 'entree' && reconciliationByEntree.get(item.id) && reconciliationByEntree.get(item.id).count > 0" v-if="item.type === 'entree' && reconciliationByEntree.get(item.id) && reconciliationByEntree.get(item.id).count > 0"
class="figli-recon-badge" class="figli-recon-badge is-clickable"
:class="{'is-anomalie': reconciliationByEntree.get(item.id).surVerse > 0, 'is-reste': reconciliationByEntree.get(item.id).resteAVerser > 0}" :class="{'is-anomalie': reconciliationByEntree.get(item.id).surVerse > 0, 'is-reste': reconciliationByEntree.get(item.id).resteAVerser > 0}"
:title="reconciliationByEntree.get(item.id).detail" :title="reconciliationByEntree.get(item.id).detail"
@click="toggleEntreeFilter(item.id)" @click="toggleEntreeFilter(item.id)"
@@ -161,8 +161,9 @@
<span <span
v-if="versementStatus(item)" v-if="versementStatus(item)"
class="figli-recon-badge" class="figli-recon-badge"
:class="{'is-anomalie': versementStatus(item).kind !== 'reste', 'is-reste': versementStatus(item).kind === 'reste'}" :class="{'is-anomalie': versementStatus(item).kind !== 'reste', 'is-reste': versementStatus(item).kind === 'reste', 'is-clickable': item.entreeLieeIds.length}"
:title="versementStatus(item).detail" :title="item.entreeLieeIds.length ? versementStatus(item).detail + ' -- cliquer pour voir la ou les entrées liées' : versementStatus(item).detail"
@click="item.entreeLieeIds.length && toggleEntreeFilter(item.entreeLieeIds[0])"
>⚠ {{ versementStatusLabel(versementStatus(item).kind) }}</span> >⚠ {{ versementStatusLabel(versementStatus(item).kind) }}</span>
</td> </td>
<td class="amount" :class="montantClass(item.montant_ht)">{{ formatEur(item.montant_ht) }}</td> <td class="amount" :class="montantClass(item.montant_ht)">{{ formatEur(item.montant_ht) }}</td>