Three related /lignes fixes: 1. Column widths: shrink Date/Facture/Libellé/Signalement (the four editable text columns) to free up room for the amount columns (Montant HT/TTC, the 8 compte columns, Écart), which were cramped. Date/Facture stay nowrap (already short: jj/mm/aa, Fxxxxxxx); Libellé/ Signalement keep wrapping. 2. Signalement editing: replaced the comma-separated inline text input with a small modal -- one tag per line, each with its own remove button, plus an add field at the bottom. Clearer than parsing/ retyping a whole comma list to drop one tag. Backend endpoint is unchanged (still takes a comma-joined value); only the front-end interaction model changed. 3. Scroll storm: a continuous scroll gesture fires many native 'scroll' events, and checkEdges() ran on every one of them -- each qualifying event queued its own loadOlder()/loadNewer() call (queuing, not dropping, was the previous session's fix for a *different* bug), and every queued call did a real fetch + scroll compensation regardless of whether an earlier one already moved the window away from the edge. That pileup is what looked like the same request firing over and over and dragged the scroll position around unpredictably. Fixed by guarding checkEdges() with the existing loadingOlder/ loadingNewer flags so it stops queuing once one's already in flight. (A first attempt at this suppressed the compensation write's own resulting scroll event via a flag cleared on requestAnimationFrame -- reproduced, live, the exact "stuck forever" failure already fixed once this session for the old rAF-based scroll throttle, because rAF doesn't reliably fire in this environment. Removed: turns out no suppression is needed at all, since that event finds loadingOlder already true and the checkEdges() guard blocks it on its own.) Also added a re-entrancy guard to pollForChanges(), which had no protection against a slow response overlapping with the next setInterval tick. Verified with scripted scroll stress tests (dense bursts of 40-100 events, and repeated attempts to scroll back from the very top): exactly one fetch per genuine edge crossing, no duplicate requests, scroll position stays correctly anchored, never snaps back down. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
352 lines
19 KiB
Twig
352 lines
19 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>
|
|
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</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
|
|
<span class="figli-filter-row">
|
|
<div class="figli-multiselect">
|
|
<div class="figli-multiselect-trigger">{{ filterCompte.length ? filterCompte.join(', ') : 'Tous' }} <span class="figli-multiselect-arrow">▾</span></div>
|
|
<div class="figli-multiselect-panel">
|
|
<label v-for="c in allComptes" :key="c" class="figli-multiselect-option">
|
|
<input type="checkbox" :value="c" v-model="filterCompte" /> {{ c }}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<button v-if="filterCompte.length" type="button" class="figli-filter-clear" @click="filterCompte = []" title="Effacer ce filtre">✕</button>
|
|
</span>
|
|
</label>
|
|
|
|
<label>Client
|
|
<span class="figli-filter-row">
|
|
<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>
|
|
<button v-if="filterClient" type="button" class="figli-filter-clear" @click="filterClient = ''" title="Effacer ce filtre">✕</button>
|
|
</span>
|
|
</label>
|
|
|
|
<label>Type
|
|
<span class="figli-filter-row">
|
|
<div class="figli-multiselect">
|
|
<div class="figli-multiselect-trigger">{{ filterType.length ? filterType.map(typeLabel).join(', ') : 'Tous' }} <span class="figli-multiselect-arrow">▾</span></div>
|
|
<div class="figli-multiselect-panel">
|
|
<label v-for="t in allTypes" :key="t.value" class="figli-multiselect-option">
|
|
<input type="checkbox" :value="t.value" v-model="filterType" /> {{ t.label }}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<button v-if="filterType.length" type="button" class="figli-filter-clear" @click="filterType = []" title="Effacer ce filtre">✕</button>
|
|
</span>
|
|
</label>
|
|
|
|
<label>Année
|
|
<span class="figli-filter-row">
|
|
<select v-model="filterYear">
|
|
<option value="">Toutes</option>
|
|
<option v-for="y in allYearsList" :key="y" :value="y">{{ y }}</option>
|
|
</select>
|
|
<button v-if="filterYear" type="button" class="figli-filter-clear" @click="filterYear = ''" title="Effacer ce filtre">✕</button>
|
|
</span>
|
|
</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>
|
|
|
|
<label class="figli-checkbox">
|
|
<input type="checkbox" v-model="onlyFlagged" /> Signalées uniquement
|
|
</label>
|
|
|
|
<datalist id="figli-flag-datalist">
|
|
<option v-for="f in allFlagsList" :key="f" :value="f"></option>
|
|
</datalist>
|
|
|
|
<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>Date</th>
|
|
<th>Client</th>
|
|
<th>Type</th>
|
|
<th>Facture</th>
|
|
<th>Libellé / Détail</th>
|
|
<th>Signalement</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="7 + 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="7 + allComptes.length + 3">{{ item.label }} <span class="figli-note">({{ item.count }} lignes)</span></td>
|
|
</tr>
|
|
<tr v-else :class="{'figli-flag-row': item.hasFlag}" :data-year="item.date ? item.date.slice(0, 4) : null">
|
|
<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>
|
|
<input
|
|
v-if="isEditingCell(item, 'client')"
|
|
v-focus
|
|
type="text"
|
|
class="figli-inline-input"
|
|
list="figli-client-datalist"
|
|
:value="item.client"
|
|
@change="saveCell(item, 'client', $event)"
|
|
@blur="editingCell = null"
|
|
@keyup.enter="$event.target.blur()"
|
|
/>
|
|
<span v-else class="figli-editable-cell" title="Cliquer pour modifier" @click="startEditCell(item, 'client')">{{ item.client || '—' }}</span>
|
|
</td>
|
|
<td>
|
|
<select
|
|
v-if="editingTypeId === item.id"
|
|
v-focus
|
|
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>
|
|
<input
|
|
v-if="isEditingCell(item, 'facture')"
|
|
v-focus
|
|
type="text"
|
|
class="figli-inline-input"
|
|
:value="item.facture"
|
|
@change="saveCell(item, 'facture', $event)"
|
|
@blur="editingCell = null"
|
|
@keyup.enter="$event.target.blur()"
|
|
/>
|
|
<span v-else class="figli-editable-cell" title="Cliquer pour modifier" @click="startEditCell(item, 'facture')">{{ item.facture || '—' }}</span>
|
|
</td>
|
|
<td class="figli-libelle">
|
|
<input
|
|
v-if="isEditingCell(item, 'libelle')"
|
|
v-focus
|
|
type="text"
|
|
class="figli-inline-input"
|
|
:value="item.libelle"
|
|
@change="saveCell(item, 'libelle', $event)"
|
|
@blur="editingCell = null"
|
|
@keyup.enter="$event.target.blur()"
|
|
/>
|
|
<template v-else>
|
|
<span class="figli-editable-cell" title="Cliquer pour modifier" @click="startEditCell(item, 'libelle')">{{ item.libelle || '—' }}</span>
|
|
<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="linkStatus(item) && item.type !== 'hebergement'"
|
|
class="figli-recon-badge is-clickable"
|
|
:class="linkStatusClasses(linkStatus(item).kind)"
|
|
:title="item.entreeLieeIds.length ? linkStatus(item).detail + ' -- cliquer pour voir la ou les entrées liées' : linkStatus(item).detail + ' -- cliquer pour lier une entrée client'"
|
|
@click="item.entreeLieeIds.length ? toggleEntreeFilter(item.entreeLieeIds[0], item.nid) : openLinkForm(item.nid)"
|
|
>{{ linkStatus(item).kind === 'ok' ? '' : '⚠ ' }}{{ linkStatusLabel(linkStatus(item)) }}</span>
|
|
</template>
|
|
</td>
|
|
<td class="figli-flag-cell">
|
|
<span class="figli-editable-cell" title="Cliquer pour gérer le signalement" @click="openFlagModal(item)">
|
|
<template v-if="item.flags.length"><span v-for="f in item.flags" :key="f" class="figli-flag-badge">{{ f }}</span></template>
|
|
<template v-else>—</template>
|
|
</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="7 + 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 colspan="6">
|
|
Solde {{ currentYear || '…' }} (créditeur / débiteur)
|
|
<span v-if="currentYearLoading" class="figli-note">chargement…</span>
|
|
</td>
|
|
<td class="amount">{{ currentYearTotals ? formatEur(currentYearTotals.montant_ht) : '' }}</td>
|
|
<td class="amount">{{ currentYearTotals ? formatEur(currentYearTotals.montant_ttc) : '' }}</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="currentYearTotals ? soldeClass(currentYearTotals.ecart) : ''">{{ currentYearTotals ? formatEur(currentYearTotals.ecart) : '' }}</td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
|
|
<div v-if="filterEntreeId" class="figli-modal-backdrop" @click.self="closeDrilldown">
|
|
<div class="figli-modal">
|
|
<div class="figli-modal-header">
|
|
<h3>Entrées et sorties liées <span v-if="groupLoading" class="figli-note">vérification…</span></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>Facture</th>
|
|
<th>Libellé / Détail</th>
|
|
<th class="amount">Montant HT</th>
|
|
<th class="amount">Montant TTC</th>
|
|
<th v-for="c in modalComptes" :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">
|
|
<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>{{ item.facture }}</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="linkStatus(item) && item.type !== 'hebergement'"
|
|
class="figli-recon-badge"
|
|
:class="linkStatusClasses(linkStatus(item).kind)"
|
|
>{{ linkStatus(item).kind === 'ok' ? '' : '⚠ ' }}{{ linkStatusLabel(linkStatus(item)) }}</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 modalComptes" :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="5">Solde entrées et 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 modalComptes" :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 v-if="flagModalItem" class="figli-modal-backdrop" @click.self="closeFlagModal">
|
|
<div class="figli-modal figli-flag-modal">
|
|
<div class="figli-modal-header">
|
|
<h3>Signalement -- {{ formatDate(flagModalItem.date) }}{{ flagModalItem.client ? ' · ' + flagModalItem.client : '' }}</h3>
|
|
<button type="button" class="figli-modal-close" @click="closeFlagModal">✕ Fermer</button>
|
|
</div>
|
|
<div class="figli-flag-modal-body">
|
|
<p v-if="!flagModalItem.flags.length" class="figli-note">Aucun signalement pour cette ligne.</p>
|
|
<div v-for="f in flagModalItem.flags" :key="f" class="figli-flag-modal-row">
|
|
<span class="figli-flag-badge">{{ f }}</span>
|
|
<button type="button" class="figli-flag-remove" title="Retirer ce signalement" @click="removeFlagTag(f)">✕</button>
|
|
</div>
|
|
<div class="figli-flag-modal-add">
|
|
<input
|
|
v-focus
|
|
type="text"
|
|
list="figli-flag-datalist"
|
|
placeholder="Ajouter un signalement…"
|
|
v-model="flagModalNewTag"
|
|
@keyup.enter="addFlagTag"
|
|
/>
|
|
<button type="button" class="button" @click="addFlagTag">Ajouter</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endverbatim %}
|