New plain string field on ligne_comptable, positioned after Client in both form and view displays. Added as a "Facture" column in both the main table and the entrée/sortie drill-down modal, right after Type. Backfilled via figli_compta_ledger_update_8002() by extracting an invoice number from field_notes (or title, same fallback the front-end libellé already uses) wherever the pattern is unambiguous: literal F, optional _/-, 2-8 digits, optional "-digits" continuations for a compound reference (e.g. "F2549-50-51"), optional 0-3 trailing uppercase letters (e.g. "F250427A"), with a hard boundary right after -- not immediately followed by more letters/digits/underscore. That last part is what skips "F58_260506_FIGLI" or "F_2601_FIGLI_EPAU": no way to tell whether the trailing "_xxx" belongs to the reference or is an unrelated client/description code glued on, so those are left blank rather than guessed, per explicit instruction to skip when unsure. Verified the pattern against every existing ligne_comptable's notes/title before writing the migration: 319 confident matches with no false positives found on manual review of the full list, 1236 left blank. Applied via drush updb, config exported.
288 lines
16 KiB
Twig
288 lines
16 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
|
|
<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>
|
|
|
|
<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>Facture</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="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-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>{{ 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 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)"
|
|
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]) : openLinkForm(item.nid)"
|
|
>{{ linkStatus(item).kind === 'ok' ? '' : '⚠ ' }}{{ linkStatusLabel(linkStatus(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="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 class="actions-col"></td>
|
|
<td colspan="5">
|
|
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" :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>{{ 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)"
|
|
class="figli-recon-badge"
|
|
:class="linkStatusClasses(linkStatus(item).kind)"
|
|
>{{ linkStatus(item).kind === 'ok' ? '' : '⚠ ' }}{{ linkStatusLabel(linkStatus(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 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>
|
|
{% endverbatim %}
|