Files
bachir d2f3179f01 Import de relevé bancaire CSV en libre-service (/lignes/importer-releve)
Chaque transaction du relevé devient une ligne « à trier » : type autre,
répartition vide (liseré rouge existant via field_ecart), tag field_flag
« IMP AAMMJJ » (liseré ambre + filtre existants), client rapproché par
mots (ClientMatcher : séquence contiguë, sinon mot significatif unique --
jamais en sous-chaîne, jamais auto-créé).

- src/Import/ : CsvReleveParser (ISO-8859-1 confirmé, en-tête strict,
  fgetcsv avec escape '' explicite -- dépréciation PHP 8.4, rejets
  propres avec n° de ligne), ReleveImportBatch (Batch API par lots de
  25, dédoublonnage COMPTÉ par empreinte field_import_fitid -- max(0,
  k−m) importe les vrais doublons légitimes et dédoublonne à travers
  des fichiers qui se chevauchent --, totaux de contrôle au centime
  sur la page de résultat, résumé en tempstore privé).
- ReleveUploadForm : upload private://releves (fichier conservé +
  usage, hors de portée du cron), parse en validateForm(), batch,
  redirection vers la page de résultat.
- field_montant_releve : référence bancaire immuable, écrite une fois
  à l'import et jamais par presave ; affichée en TEXTE sous Montant
  TTC (widget remplacé par un #type item -- un item ne soumet rien et
  extractFormValues() saute le champ sans valeur soumise, la valeur
  survit donc à chaque save) ; masquée sur les lignes sans montant.
- SkipValidationContext : contournement du contrôle de répartition
  requête-scopé (ferme le trou de concurrence de l'ancien state
  global, AUDIT-2026-09-09 §2.2) -- presave honore le service (state
  gardé pour compat), updateType/updateField basculent dessus.
- Permissions (AUDIT §2.2 priorité 1) : access figli ledger sur toutes
  les routes du module + autocomplete (RouteSubscriber), import
  réservé Éditeur/Admin, access content retiré du rôle Authenticated
  (config/sync re-exportée pour les 4 rôles).
- Gin : hook_gin_ignore_sticky_form_actions() -- sans ça, le bouton
  Importer partait dans la barre sticky du chrome masqué.
- install : 8011 champs, 8012 index sur les empreintes, 8013/8014
  montant_releve sur le formulaire sous le TTC (poids renumérotés).
- /lignes : boutons + Ajouter / Importer / Historique dans le footer
  sticky (compacts), footer colspan dès la première colonne, badges de
  signalement qui reviennent à la ligne au lieu de déborder.
2026-09-09 14:58:36 +02:00

382 lines
21 KiB
Twig

{#
Dashboard shell: Drupal renders the page (nav, auth, permissions).
home.js (Vue 3) renders a spreadsheet-like table of every ligne comptable,
with filters and month/year grouping applied server-side (see
LedgerRowsController). "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' : '' }}">SAS</a>
<a href="{{ path('figli_compta_ledger.dashboard_repartition') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_repartition' ? 'is-active' : '' }}">Répartition/Soldes</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">
<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>Libellé / Détails
<span class="figli-filter-row">
<input type="text" v-model="filterQ" placeholder="Rechercher…" autocomplete="off" class="figli-client-input" />
<button v-if="filterQ" type="button" class="figli-filter-clear" @click="filterQ = ''" 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>Signalement
<span class="figli-filter-row">
<div class="figli-multiselect">
<div class="figli-multiselect-trigger">{{ filterFlag.length ? filterFlag.join(', ') : 'Tous' }} <span class="figli-multiselect-arrow">▾</span></div>
<div class="figli-multiselect-panel">
<label v-for="f in allFlagsList" :key="f" class="figli-multiselect-option">
<input type="checkbox" :value="f" v-model="filterFlag" /> {{ f }}
</label>
<p v-if="!allFlagsList.length" class="figli-note">Aucun signalement existant.</p>
</div>
</div>
<button v-if="filterFlag.length" type="button" class="figli-filter-clear" @click="filterFlag = []" 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">{{ rows.length }} ligne{{ rows.length > 1 ? 's' : '' }} chargée{{ rows.length > 1 ? 's' : '' }}{{ 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 figli-ht-col">HT</th>
<th class="amount figli-cotisation-col">1,1%</th>
<th class="amount figli-tva-col">TVA</th>
<th class="amount figli-ttc-col">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 + 5">
<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 + 5">{{ 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 figli-ht-col" :class="montantClass(item.montant_ht)">{{ formatEur(item.montant_ht) }}</td>
<td class="amount figli-cotisation-col">{{ formatEur(item.cotisation) }}</td>
<td class="amount figli-tva-col" :class="{'figli-tva-nonofficial': !isTvaOfficial(item.tva)}" :title="!isTvaOfficial(item.tva) ? 'Taux TVA non officiel (ni 0, 2,1, 5,5, 10 ni 20 %)' : null">{{ formatPct(item.tva) }}</td>
<td class="amount figli-ttc-col">{{ 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.ecart < 0, 'figli-ecart-positif': item.hasError && item.ecart > 0}">{{ item.hasError ? formatEur(item.ecart) : '' }}</td>
</tr>
</template>
<tr ref="bottomSentinel" class="figli-sentinel-row">
<td :colspan="7 + allComptes.length + 5">
<span v-if="loadingNewer">Chargement des mois suivants…</span>
</td>
</tr>
</tbody>
<tfoot>
<tr class="figli-totals-row">
<td colspan="7">
<a href="/node/add/ligne_comptable" class="button button--primary" @click.prevent="openAddForm">+ Ajouter une ligne</a>
{% endverbatim %}
{% if can_import_releve %}
<a href="{{ path('figli_compta_ledger.releve_import_form') }}" class="button">Importer un relevé</a>
{% endif %}
{% if can_view_history %}
<a href="{{ path('figli_compta_ledger.history') }}" class="button">Historique</a>
{% endif %}
{% verbatim %}
Solde {{ currentYear || '…' }} (créditeur / débiteur)
<span v-if="currentYearLoading" class="figli-note">chargement…</span>
</td>
<td class="amount figli-ht-col">{{ currentYearTotals ? formatEur(currentYearTotals.montant_ht) : '' }}</td>
<td class="amount figli-cotisation-col">{{ currentYearTotals ? formatEur(currentYearTotals.cotisation) : '' }}</td>
<td class="amount figli-tva-col"></td>
<td class="amount figli-ttc-col">{{ 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.ecart < 0, 'figli-ecart-positif': item.hasError && item.ecart > 0}">{{ item.hasError ? formatEur(item.ecart) : '' }}</td>
</tr>
</tbody>
<tfoot>
<tr class="figli-totals-row">
<td colspan="6">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 %}