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.
202 lines
10 KiB
Twig
202 lines
10 KiB
Twig
{#
|
|
Dashboard shell: Drupal renders the page (nav, auth, permissions).
|
|
dashboard.js (Vue 3) fetches JSON:API and renders a spreadsheet-like table
|
|
of every ligne comptable, with filters and month/year grouping, client-side.
|
|
"Ajouter une ligne" opens the real Drupal node form in a modal
|
|
(core/drupal.dialog.ajax) -- no form logic duplicated in JS.
|
|
#}
|
|
<nav class="figli-page-nav">
|
|
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
|
|
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">Dashboard</a>
|
|
</nav>
|
|
{% verbatim %}
|
|
<div id="figli-home-app">
|
|
<div class="figli-toolbar">
|
|
<a href="/node/add/ligne_comptable" class="button button--primary" @click.prevent="openAddForm">+ Ajouter une ligne</a>
|
|
{% endverbatim %}
|
|
{% if can_view_history %}
|
|
<a href="{{ path('figli_compta_ledger.history') }}" class="button">Historique</a>
|
|
{% endif %}
|
|
{% verbatim %}
|
|
|
|
<label>Compte
|
|
<select v-model="filterCompte">
|
|
<option value="">Tous</option>
|
|
<option v-for="c in allComptes" :key="c" :value="c">{{ c }}</option>
|
|
</select>
|
|
</label>
|
|
|
|
<label>Client
|
|
<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
|
|
<select v-model="filterType">
|
|
<option value="">Tous</option>
|
|
<option v-for="t in allTypes" :key="t.value" :value="t.value">{{ t.label }}</option>
|
|
</select>
|
|
</label>
|
|
|
|
<label>Année
|
|
<select v-model="filterYear">
|
|
<option value="">Toutes</option>
|
|
<option v-for="y in allYearsList" :key="y" :value="y">{{ y }}</option>
|
|
</select>
|
|
</label>
|
|
|
|
<label>Aller à
|
|
<select v-model="jumpYearValue" @change="onJumpYearChange">
|
|
<option value="">Aller à…</option>
|
|
<option v-for="y in allYearsList" :key="y" :value="y">{{ y }}</option>
|
|
</select>
|
|
</label>
|
|
|
|
<label>Regrouper par
|
|
<select v-model="groupBy">
|
|
<option value="none">Aucun</option>
|
|
<option value="month">Mois</option>
|
|
<option value="year">Année</option>
|
|
</select>
|
|
</label>
|
|
|
|
<label class="figli-checkbox">
|
|
<input type="checkbox" v-model="onlyErrors" /> Écarts uniquement
|
|
</label>
|
|
|
|
<button type="button" class="button figli-clear-drilldown" v-if="filterEntreeId" @click="filterEntreeId = null">✕ Entrée + sorties liées uniquement</button>
|
|
|
|
<span class="figli-count" v-if="!loading">{{ filteredRows.length }} / {{ rows.length }} lignes chargées{{ errorCount ? ' — ' + errorCount + ' avec écart' : '' }}</span>
|
|
</div>
|
|
|
|
<p v-if="typeUpdateError" class="figli-error figli-inline-error">Erreur : {{ typeUpdateError }} <button type="button" class="figli-clear-drilldown" @click="typeUpdateError = null">✕</button></p>
|
|
|
|
<p v-if="loading">Chargement des données…</p>
|
|
<p v-else-if="error" class="figli-error">Erreur de chargement : {{ error }}</p>
|
|
<div v-else ref="tableWrap" class="figli-table-wrap">
|
|
<table ref="tableEl" @mouseover="onCellHover" @mouseleave="clearColHover">
|
|
<thead>
|
|
<tr>
|
|
<th class="actions-col"></th>
|
|
<th class="actions-col"></th>
|
|
<th>Date</th>
|
|
<th>Client</th>
|
|
<th>Type</th>
|
|
<th>Libellé / Détail</th>
|
|
<th class="amount">Montant HT</th>
|
|
<th class="amount">Montant TTC</th>
|
|
<th v-for="c in allComptes" :key="c" class="amount compte-col">{{ c }}</th>
|
|
<th class="amount">Écart</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr ref="topSentinel" class="figli-sentinel-row">
|
|
<td :colspan="6 + allComptes.length + 3">
|
|
<span v-if="loadingOlder">Chargement des mois précédents…</span>
|
|
</td>
|
|
</tr>
|
|
<template v-for="item in groupedRows" :key="item.key">
|
|
<tr v-if="item.isGroup" class="figli-group-row" :data-year="item.year">
|
|
<td :colspan="6 + allComptes.length + 3">{{ item.label }} <span class="figli-note">({{ item.count }} lignes)</span></td>
|
|
</tr>
|
|
<tr v-else :class="{'figli-error-row': item.hasError}" :data-year="item.date ? item.date.slice(0, 4) : null">
|
|
<td class="actions-col">
|
|
<button
|
|
v-if="item.linkable"
|
|
type="button"
|
|
class="figli-link-btn"
|
|
: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">
|
|
<path d="M8 12a3 3 0 0 0 4.24 0l2-2a3 3 0 0 0-4.24-4.24l-1 1" />
|
|
<path d="M12 8a3 3 0 0 0-4.24 0l-2 2a3 3 0 0 0 4.24 4.24l1-1" />
|
|
</svg>
|
|
</button>
|
|
</td>
|
|
<td class="actions-col">
|
|
<button type="button" class="figli-edit-btn" title="Modifier" @click="openEditForm(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">
|
|
<path d="M13.5 3.5l3 3L6 17l-3.5.5.5-3.5L13.5 3.5z" />
|
|
</svg>
|
|
</button>
|
|
</td>
|
|
<td>{{ formatDate(item.date) }}</td>
|
|
<td>{{ item.client || '—' }}</td>
|
|
<td>
|
|
<select
|
|
v-if="editingTypeId === item.id"
|
|
class="figli-type-select"
|
|
:value="item.type"
|
|
@change="saveType(item, $event)"
|
|
@blur="editingTypeId = null"
|
|
>
|
|
<option v-for="t in allTypes" :key="t.value" :value="t.value">{{ t.label }}</option>
|
|
</select>
|
|
<span
|
|
v-else
|
|
class="figli-badge figli-badge-editable"
|
|
:class="'type-' + item.type"
|
|
title="Cliquer pour changer le type"
|
|
@click="startEditType(item)"
|
|
>{{ typeLabel(item.type) }}</span>
|
|
</td>
|
|
<td class="figli-libelle">
|
|
{{ item.libelle }}
|
|
<span
|
|
v-if="item.type === 'entree' && reconciliationByEntree.get(item.id) && reconciliationByEntree.get(item.id).count > 0"
|
|
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)"
|
|
>{{ reconciliationByEntree.get(item.id).count }} sortie{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }} liée{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }}<template v-if="reconciliationByEntree.get(item.id).resteAVerser > 0"> · reste {{ formatEur(reconciliationByEntree.get(item.id).resteAVerser) }}</template><template v-if="reconciliationByEntree.get(item.id).surVerse > 0"> · sur-versé {{ formatEur(reconciliationByEntree.get(item.id).surVerse) }}</template></span>
|
|
<span
|
|
v-if="item.type === 'ouverture' && ouvertureEcart(item)"
|
|
class="figli-recon-badge is-anomalie"
|
|
:title="'Écart avec la clôture calculée de ' + (item.date.slice(0, 4) - 1) + ' : ' + ouvertureEcart(item).detail"
|
|
>⚠ écart clôture {{ item.date.slice(0, 4) - 1 }} ({{ ouvertureEcart(item).comptes }} compte{{ ouvertureEcart(item).comptes > 1 ? 's' : '' }})</span>
|
|
<span
|
|
v-if="versementStatus(item)"
|
|
class="figli-recon-badge"
|
|
: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>
|
|
<td class="amount">{{ formatEur(item.montant_ttc) }}</td>
|
|
<td v-for="c in allComptes" :key="c" class="amount compte-col">{{ item.parCompte[c] !== undefined ? formatEur(item.parCompte[c]) : '' }}</td>
|
|
<td class="amount" :class="{'figli-ecart': item.hasError}">{{ item.hasError ? formatEur(item.ecart) : '' }}</td>
|
|
</tr>
|
|
</template>
|
|
<tr ref="bottomSentinel" class="figli-sentinel-row">
|
|
<td :colspan="6 + allComptes.length + 3">
|
|
<span v-if="loadingNewer">Chargement des mois suivants…</span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
<tfoot>
|
|
<tr class="figli-totals-row">
|
|
<td class="actions-col"></td>
|
|
<td class="actions-col"></td>
|
|
<td colspan="4">
|
|
<template v-if="filterEntreeId">Solde entrée + sorties liées (créditeur / débiteur)</template>
|
|
<template v-else>
|
|
Solde {{ currentYear || '…' }} (créditeur / débiteur)
|
|
<span v-if="currentYearLoading" class="figli-note">chargement…</span>
|
|
</template>
|
|
</td>
|
|
<td class="amount">{{ footerTotals ? formatEur(footerTotals.montant_ht) : '' }}</td>
|
|
<td class="amount">{{ footerTotals ? formatEur(footerTotals.montant_ttc) : '' }}</td>
|
|
<td v-for="c in allComptes" :key="c" class="amount compte-col" :class="footerTotals ? soldeClass(footerTotals.par_compte[c]) : ''">{{ footerTotals && footerTotals.par_compte[c] !== undefined ? formatEur(footerTotals.par_compte[c]) : '' }}</td>
|
|
<td class="amount" :class="footerTotals ? soldeClass(footerTotals.ecart) : ''">{{ footerTotals ? formatEur(footerTotals.ecart) : '' }}</td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
{% endverbatim %}
|