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
module: core
locked: false
cardinality: 1
cardinality: -1
translatable: true
indexes: { }
persist_with_no_fields: false
@@ -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;
@@ -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();
@@ -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();
}
@@ -27,10 +27,10 @@
</label>
<label>Client
<select v-model="filterClient">
<option value="">Tous</option>
<option v-for="c in allClientsList" :key="c" :value="c">{{ c }}</option>
</select>
<input type="text" v-model.lazy="filterClient" list="figli-client-datalist" placeholder="Tous" autocomplete="off" class="figli-client-input" />
<datalist id="figli-client-datalist">
<option v-for="c in allClientsList" :key="c" :value="c"></option>
</datalist>
</label>
<label>Type
@@ -107,8 +107,8 @@
v-if="item.linkable"
type="button"
class="figli-link-btn"
:class="{'is-linked': item.entreeLieeId}"
:title="item.entreeLieeId ? 'Lié à : ' + item.entreeLieeLabel : 'Lier à une entrée client'"
:class="{'is-linked': item.entreeLieeIds.length}"
:title="item.entreeLieeIds.length ? 'Lié à : ' + item.entreeLieeLabels.join(', ') : 'Lier à une entrée client'"
@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">
@@ -148,7 +148,7 @@
{{ item.libelle }}
<span
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}"
:title="reconciliationByEntree.get(item.id).detail"
@click="toggleEntreeFilter(item.id)"
@@ -161,8 +161,9 @@
<span
v-if="versementStatus(item)"
class="figli-recon-badge"
:class="{'is-anomalie': versementStatus(item).kind !== 'reste', 'is-reste': versementStatus(item).kind === 'reste'}"
:title="versementStatus(item).detail"
:class="{'is-anomalie': versementStatus(item).kind !== 'reste', 'is-reste': versementStatus(item).kind === 'reste', 'is-clickable': item.entreeLieeIds.length}"
: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>
</td>
<td class="amount" :class="montantClass(item.montant_ht)">{{ formatEur(item.montant_ht) }}</td>