Open the entrée/versement drill-down in a modal instead of in place

Replacing the main table's rows with the filtered group made the browser
clamp scrollTop to 0 the moment the drill-down shrank the visible content,
so closing it never returned to where the user had been scrolled. A modal
overlay leaves the main table (and its scroll position) untouched entirely.
This commit is contained in:
2026-09-05 13:16:44 +02:00
parent 0f33d57ec0
commit 9ec0aa4699
3 changed files with 151 additions and 33 deletions
@@ -332,6 +332,68 @@ html.gin--dark-mode #figli-home-app {
padding: 0.2rem 0.6rem; padding: 0.2rem 0.6rem;
} }
/* Entrée + sorties liées drill-down: a modal overlay rather than
replacing the main table's rows in place, so opening/closing it never
disturbs the main table's scroll position. */
#figli-home-app .figli-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
#figli-home-app .figli-modal {
background: var(--figli-bg);
color: var(--figli-text);
border: 1px solid var(--figli-border);
border-radius: 8px;
max-width: 90vw;
max-height: 85vh;
width: 900px;
display: flex;
flex-direction: column;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
#figli-home-app .figli-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--figli-border);
}
#figli-home-app .figli-modal-header h3 {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
}
#figli-home-app .figli-modal-close {
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: var(--figli-text-light);
cursor: pointer;
font-size: 0.9rem;
padding: 0.15rem 0.45rem;
}
#figli-home-app .figli-modal-close:hover {
background: var(--figli-bg-alt);
border-color: var(--figli-border);
color: var(--figli-text);
}
#figli-home-app .figli-modal-body {
overflow: auto;
padding: 0;
}
#figli-home-app .figli-modal-body table {
font-size: 0.8rem;
}
#figli-home-app .figli-modal-body thead th {
position: sticky;
top: 0;
}
/* Sliding-window edge markers (IntersectionObserver targets) -- kept /* Sliding-window edge markers (IntersectionObserver targets) -- kept
short so they don't add visible dead space when idle, tall enough short so they don't add visible dead space when idle, tall enough
(min-height) to reliably intersect the observer's root margin. */ (min-height) to reliably intersect the observer's root margin. */
@@ -409,11 +409,6 @@
return [...entrees, ...sorties]; return [...entrees, ...sorties];
}, },
filteredRows() { filteredRows() {
// Drill-down mode: the connected entrée/sortie group, ignoring
// the other filters -- clicking the badge again clears it.
if (this.filterEntreeId) {
return this.filterEntreeGroup;
}
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;
if (this.filterClient && r.client !== this.filterClient) return false; if (this.filterClient && r.client !== this.filterClient) return false;
@@ -423,22 +418,21 @@
return true; return true;
}); });
}, },
// Footer totals while drilled down into one entrée + its linked // Totals for the entrée + linked sorties drill-down modal (see
// sorties (see filteredRows' drill-down branch above): the whole // filterEntreeGroup above): the whole point of that view is "does
// point of this view is "does this entrée balance against what was // this entrée balance against what was paid out", so its own footer
// paid out", so the footer should answer exactly that instead of // answers exactly that instead of the current year's totals -- and
// the current year's totals -- and it can be computed locally // it can be computed locally (unlike the per-year figures, this
// (unlike the per-year figures, this handful of rows is already // handful of rows is already fully loaded), no server round-trip
// fully loaded), no server round-trip needed. Same shape as // needed. Same shape as /lignes/api/totaux so the template can
// /lignes/api/totaux so the template can render either the same // render either the same way.
// way.
drilldownTotals() { drilldownTotals() {
if (!this.filterEntreeId) return null; if (!this.filterEntreeId) return null;
let montantHt = 0; let montantHt = 0;
let montantTtc = 0; let montantTtc = 0;
let ecart = 0; let ecart = 0;
const parCompte = {}; const parCompte = {};
for (const r of this.filteredRows) { for (const r of this.filterEntreeGroup) {
montantHt += r.montant_ht || 0; montantHt += r.montant_ht || 0;
montantTtc += r.montant_ttc || 0; montantTtc += r.montant_ttc || 0;
ecart += r.ecart || 0; ecart += r.ecart || 0;
@@ -454,13 +448,6 @@
par_compte: Object.fromEntries(Object.entries(parCompte).map(([c, v]) => [c, round(v)])), par_compte: Object.fromEntries(Object.entries(parCompte).map(([c, v]) => [c, round(v)])),
}; };
}, },
// What the footer actually renders -- the drill-down's own totals
// while active, otherwise the current year's (see
// detectCurrentYear()/loadCurrentYearTotals()). Switches back
// automatically the moment filterEntreeId clears.
footerTotals() {
return this.filterEntreeId ? this.drilldownTotals : this.currentYearTotals;
},
groupedRows() { groupedRows() {
const list = this.filteredRows; const list = this.filteredRows;
if (this.groupBy === 'none') return list; if (this.groupBy === 'none') return list;
@@ -621,9 +608,16 @@
progress: { type: 'throbber' }, progress: { type: 'throbber' },
}).execute(); }).execute();
}, },
// Opens/closes the entrée + linked sorties drill-down modal -- a
// separate overlay (see the template), not a filter applied to the
// main table, so opening/closing it never touches the main table's
// scroll position.
toggleEntreeFilter(id) { toggleEntreeFilter(id) {
this.filterEntreeId = this.filterEntreeId === id ? null : id; this.filterEntreeId = this.filterEntreeId === id ? null : id;
}, },
closeDrilldown() {
this.filterEntreeId = null;
},
startEditType(item) { startEditType(item) {
this.typeUpdateError = null; this.typeUpdateError = null;
this.editingTypeId = item.id; this.editingTypeId = item.id;
@@ -66,8 +66,6 @@
<input type="checkbox" v-model="onlyErrors" /> Écarts uniquement <input type="checkbox" v-model="onlyErrors" /> Écarts uniquement
</label> </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> <span class="figli-count" v-if="!loading">{{ filteredRows.length }} / {{ rows.length }} lignes chargées{{ errorCount ? ' — ' + errorCount + ' avec écart' : '' }}</span>
</div> </div>
@@ -183,19 +181,83 @@
<td class="actions-col"></td> <td class="actions-col"></td>
<td class="actions-col"></td> <td class="actions-col"></td>
<td colspan="4"> <td colspan="4">
<template v-if="filterEntreeId">Solde entrée + sorties liées (créditeur / débiteur)</template> Solde {{ currentYear || '…' }} (créditeur / débiteur)
<template v-else> <span v-if="currentYearLoading" class="figli-note">chargement…</span>
Solde {{ currentYear || '…' }} (créditeur / débiteur)
<span v-if="currentYearLoading" class="figli-note">chargement…</span>
</template>
</td> </td>
<td class="amount">{{ footerTotals ? formatEur(footerTotals.montant_ht) : '' }}</td> <td class="amount">{{ currentYearTotals ? formatEur(currentYearTotals.montant_ht) : '' }}</td>
<td class="amount">{{ footerTotals ? formatEur(footerTotals.montant_ttc) : '' }}</td> <td class="amount">{{ currentYearTotals ? formatEur(currentYearTotals.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 v-for="c in allComptes" :key="c" class="amount compte-col" :class="currentYearTotals ? soldeClass(currentYearTotals.par_compte[c]) : ''">{{ currentYearTotals && currentYearTotals.par_compte[c] !== undefined ? formatEur(currentYearTotals.par_compte[c]) : '' }}</td>
<td class="amount" :class="footerTotals ? soldeClass(footerTotals.ecart) : ''">{{ footerTotals ? formatEur(footerTotals.ecart) : '' }}</td> <td class="amount" :class="currentYearTotals ? soldeClass(currentYearTotals.ecart) : ''">{{ currentYearTotals ? formatEur(currentYearTotals.ecart) : '' }}</td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
</div> </div>
<div v-if="filterEntreeId" class="figli-modal-backdrop" @click.self="closeDrilldown">
<div class="figli-modal">
<div class="figli-modal-header">
<h3>Entrée + sorties liées</h3>
<button type="button" class="figli-modal-close" @click="closeDrilldown">✕</button>
</div>
<div class="figli-modal-body">
<table>
<thead>
<tr>
<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 v-for="item in filterEntreeGroup" :key="item.id" :class="{'figli-error-row': item.hasError}">
<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><span class="figli-badge" :class="'type-' + item.type">{{ 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"
:class="{'is-anomalie': reconciliationByEntree.get(item.id).surVerse > 0, 'is-reste': reconciliationByEntree.get(item.id).resteAVerser > 0}"
>{{ 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="versementStatus(item)"
class="figli-recon-badge"
:class="versementStatusClasses(versementStatus(item).kind)"
>{{ versementStatus(item).kind === 'ok' ? '' : '⚠ ' }}{{ 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>
</tbody>
<tfoot>
<tr class="figli-totals-row">
<td class="actions-col"></td>
<td colspan="4">Solde entrée + sorties liées (créditeur / débiteur)</td>
<td class="amount">{{ drilldownTotals ? formatEur(drilldownTotals.montant_ht) : '' }}</td>
<td class="amount">{{ drilldownTotals ? formatEur(drilldownTotals.montant_ttc) : '' }}</td>
<td v-for="c in allComptes" :key="c" class="amount compte-col" :class="drilldownTotals ? soldeClass(drilldownTotals.par_compte[c]) : ''">{{ drilldownTotals && drilldownTotals.par_compte[c] !== undefined ? formatEur(drilldownTotals.par_compte[c]) : '' }}</td>
<td class="amount" :class="drilldownTotals ? soldeClass(drilldownTotals.ecart) : ''">{{ drilldownTotals ? formatEur(drilldownTotals.ecart) : '' }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div> </div>
{% endverbatim %} {% endverbatim %}