- ledger-form.js : nouveau behavior figliLedgerRepartition --
pré-remplit les montants de répartition (1re ligne = HT entier,
chaque ajout partage au centime avec report du reste sur les lignes
suivantes), valeurs saisies manuellement ou chargées de la base
« figées » (plus jamais déplacées), écart en direct dans la cellule
de titre du widget (miroir exact du round(HT − somme, 2) et de la
tolérance 0,01 du presave). Zéro changement PHP : #validate et
node_presave() restent l'autorité. Les verrous vivent hors du DOM
(survie aux re-rendus AJAX du widget) ; la détection manuel/assist
repose sur le fait qu'une écriture programmatique ne déclenche pas
d'événement input.
- ledger-form.css : compactage du widget -- titre « Répartition » par
ligne, bouton Collapse et colonne Order masqués, paragraph-top en
absolu (coin haut-droit, zéro hauteur), une seule ligne par
répartition « Compte [input] Montant (€) [input] » où l'input du
Compte est contraint en pourcentage de son wrapper claro-autocomplete
(size=60 : il débordait sur le libellé Montant), header réordonné
titre / écart / menu trois-points, inputs visibles au repos
(--flform-bg, atténué en mode sombre), marqueur « figé ».
- install : update_8015 (features du widget vidées) puis update_8016
(collapse_edit_all rétabli à la demande, duplicate reste off) +
settings de l'install fraîche synchronisés.
La région [data-drupal-messages] vit dans le layout Gin, où des stacking
contexts ancêtres neutralisaient son position:fixed + z-index : tout le
sous-arbre passait sous l'overlay de la modale jQuery UI (enfant direct
du <body>), rendant illisibles les messages insérés pendant l'édition
d'une ligne (MessageCommand -- erreur de répartition, création...).
admin-chrome.js déplace la région en enfant direct du <body> au
chargement et la re-vérifie dès qu'une modale entre dans le DOM ;
admin-chrome.css passe son z-index à 100000, hors d'atteinte du
_moveToTop de jQuery UI (qui ne remonte un dialog que au-dessus des
siblings .ui-front, ce que la région n'est pas).
Ligne "Total" sous le graph principal et sous chacune des cartes par
année -- somme calculée à partir des items déjà tracés (sumItems()), pas
d'un champ stats séparé, pour rester cohérente par construction avec les
barres affichées au-dessus.
Réutilise le node-level query déjà exécuté pour caParClient/totalParType
(aucune requête SQL supplémentaire) -- accumule abs(montant_ht) des
lignes type=charge par client (le vendeur/organisme : loyer, assurance,
URSSAF...). Trié par montant décroissant (pas alphabétique), pas de
plafond top-N vu le petit nombre de vendeurs distincts. Coloré en gris
"charge", même couleur que ce type dans "Répartition de l'activité par
type".
Remplace /lignes/api/totaux (LedgerStatsController::totauxAnnee(),
toujours non filtré) par une agrégation côté client des lignes renvoyées
par LedgerRowsController::index() (même endpoint que la fenêtre
glissante) avec annee=<année visible> + les filtres actifs. Reste une
requête serveur dédiée sur l'année entière (pas de LIMIT/range sur la
requête Entity), donc le total ne dépend jamais de ce qui est
effectivement chargé dans la fenêtre glissante à cet instant -- vérifié :
207 lignes non filtrées vs 13 avec le filtre "OVH", total du footer
identique au calcul indépendant dans les deux cas.
onFilterChanged() déclenche maintenant systématiquement
loadCurrentYearTotals() (pas seulement quand detectCurrentYear() détecte
un changement d'année) -- sinon changer un filtre en restant sur la même
année laissait le footer afficher l'ancien total non filtré.
totauxAnnee() et sa route sont supprimés (plus aucun appelant après ce
changement, vérifié par recherche).
Les 6 comptes sont désormais triés alphabétiquement plutôt que par
montant décroissant ; Salaire/stage et Sous-traitant ne participent pas à
ce tri, ils sont simplement ajoutés après coup, dans cet ordre fixe.
Les 6 comptes reprennent la couleur "versement" (orange, même que
"Versement freelance" dans Répartition par type) puisque c'est la même
somme, juste ventilée par bénéficiaire ; Salaire/stage et Sous-traitant
gardent leur propre couleur de type. Réutilise typeColor()/TYPE_COLORS
déjà en place, juste besoin d'un champ "type" sur chaque item pour que
colorFor puisse s'en servir.
Deux lignes supplémentaires (pas ventilées par compte, un seul total
chacune) dans "Total des versements par compte" et sa version par année --
même source déjà utilisée par "Répartition de l'activité par type"
(total_par_type / total_par_type_par_annee), aucun changement backend.
Réutilise total_par_type_par_compte / total_par_type_par_compte_par_annee,
déjà exposés par /dashboard/api/stats pour /dashboard/compte -- aucun
changement backend nécessaire, juste extrait la clé "versement" par
compte au lieu de la garder scindée par type.
Nouvelle page "Répartition/Soldes" entre "SAS" (ex-"Dashboard") et "Par
compte" dans le menu : reprend "Solde par compte" et "Évolution du solde
par compte", retirés du dashboard général pour le recentrer sur
l'activité/CA/type/client. Mêmes données déjà exposées par
/dashboard/api/stats (solde_par_compte, solde_par_compte_par_annee),
aucun changement backend nécessaire pour cette page.
js/dashboard-repartition.js reprend le HBarChart/MiniTrend de
dashboard.js -- dupliqués plutôt que partagés, même convention que
dashboard-compte.js. Racine Vue volontairement le même id
#figli-dashboard-app que les deux autres pages dashboard (pas un id
dédié) : dashboard.css scope ses variables CSS (thème clair/sombre) sur
ce sélecteur, réutiliser le même id est comment les trois pages héritent
du même thème sans feuille de style séparée -- vérifié en dark mode.
Le lien de menu "Dashboard" devient "SAS" partout (les 3 templates Twig
+ le nav en render array de HistoryController, qui n'a pas de template
Twig propre).
dashboard.js : MiniTrend/soldeParCompteItems/comptesOrdonnes/
trendValues supprimés (code mort après le déplacement, plus rien ne les
utilise sur le dashboard général).
Reprend exactement le pattern des petits multiples déjà en place sur
/dashboard/compte (grille figli-year-hbar-grid, variante compacte de
h-bar-chart) -- mêmes classes CSS, aucun nouveau style nécessaire.
Backend : DashboardStatsController::stats() calcule maintenant aussi
total_par_type_par_annee et top_clients_par_annee (top 8, contre 12 en
toutes années confondues) à partir des mêmes requêtes SQL déjà en place,
sans requête supplémentaire.
Frontend : dashboard.js n'a jamais de données ligne par ligne (contraire-
ment à dashboard-compte.js qui filtre côté client) -- l'agrégation par
année doit donc venir du serveur. Ajout du prop "compact" au HBarChart de
dashboard.js (jusqu'ici absent, seule la copie de dashboard-compte.js
l'avait).
ensureScrollable() (appelé par onFilterChanged() après chaque changement
de filtre) appelle lui-même loadOlder()/loadNewer(), qui passent par le
même _queueWindowOp -- en le chaînant *à l'intérieur* de l'opération déjà
mise en file par onFilterChanged(), la file d'attente se retrouvait à
attendre sa propre continuation dès qu'un filtre laissait trop peu de
lignes pour remplir l'écran, gelant purement et simplement le tableau
(recherche qui ne charge plus les lignes précédentes en scrollant, et
même effacer le filtre ensuite ne faisait plus rien -- tout attendait
derrière l'opération bloquée).
Corrigé en chaînant ensureScrollable() après la résolution de l'opération
mise en file, pas dedans -- ses propres appels à loadOlder()/loadNewer()
s'empilent alors normalement sur la file, sans dépendance circulaire.
Reproduit et vérifié en conditions réelles : recherche "Assurance local"
(47 correspondances de 2023 à 2026) qui chargeait bien 2026 mais bloquait
en scrollant vers le haut -- après correctif, chaque scroll vers le haut
déclenche bien un nouveau chargement (vérifié sur 2 scrolls successifs),
et effacer le filtre recharge immédiatement la vue complète.
Remplace le "charge tout puis filtre côté client" (filteredRows()) par un
nouvel endpoint /lignes/api/lignes (LedgerRowsController), qui pousse tous
les filtres de la barre d'outils dans une seule requête Entity/Field Query
API (pas de SQL brut) -- y compris à travers la relation
field_repartition -> field_compte (paragraph -> taxonomie), confirmé
fonctionner empiriquement avant d'écrire le contrôleur. `rows` ne contient
donc plus que ce qui est à la fois dans la fenêtre de dates ET dans les
filtres actifs ; la fenêtre glissante elle-même (loadOlder/loadNewer/
ensureScrollable) est inchangée, seul ce qui la peuple change.
Ajouts :
- Nouveau filtre "Libellé / Détails" (recherche plein texte sur
field_notes ou le titre), débouncé côté client (350ms).
- filterYear (l'"Année" dédiée) passe par le même endpoint via son
paramètre "annee".
- mergeChangedRows() (le merge du polling) tient maintenant compte des
filtres actifs : une ligne qui ne correspond plus après un changement
est retirée de `rows`, une ligne qui correspond nouvellement est ajoutée
-- polling lui-même reste global/non filtré, seul le merge est
filter-aware.
JSON:API reste utilisé pour ce que l'endroit filtré ne couvre pas : le
groupe entrée/sorties liées (fetchLignesByNids) et le polling
(fetchChangedSince), tous deux indépendants d'une plage de dates+filtres.
Testé en local : chaque filtre individuellement et combiné (compte+q,
client+type), widening de fenêtre sous filtre restrictif, restauration
combinée depuis le hash au reload, modale d'édition + reloadWindow après
fermeture, polling sans erreur.
- Un point ambre discret (pas de texte -- la colonne TVA ne fait que
3,5% de large, à peine assez pour "12,64 %" lui-même) marque tout
taux TVA qui ne tombe sur aucun des 5 taux officiels français (0,
2,1, 5,5, 10, 20 %, ±0,05 point comme dans les scripts de migration).
Infobulle pour le détail.
- Écart positif (répartition > Montant HT) passe au vert
(--figli-positive), au lieu du rouge uniforme précédent -- même
logique que la colonne Montant HT, qui distingue déjà positif/négatif.
field_cotisation_urssaf stockait round(HT * 1.011, 2) (le montant
augmenté) -- "1,1%" comme libellé de champ/colonne désigne la
cotisation elle-même, pas HT + cotisation. Change pour
round(HT * 0.011, 2). La TVA continue de s'appliquer sur la base
augmentée (HT + ce champ) : Montant TTC est inchangé par cette
correction, seule la valeur affichée/stockée dans "1,1%" change.
Migration (update hook 8008) : recalcule field_cotisation_urssaf pour
les 154 lignes que figli_compta_ledger_update_8007() avait marquées
cotisation active, en soustrayant simplement le HT déjà correct --
contrairement à cette dernière, ce n'est pas un arbitrage sur des
données historiques ambiguës, juste un bug dans du code écrit plus tôt
le même jour, donc recalculé sans condition ni prudence particulière.
Ajoute aussi le total "1,1%" au pied du tableau (solde de l'année),
absent jusqu'ici : LedgerStatsController::totauxAnnee() somme
maintenant field_cotisation_urssaf comme il le fait déjà pour HT/TTC.
Architecture métier corrigée : la SAS devise HT, ajoute (quand
applicable -- pas systématique) 1,1% de cotisation diffuseur URSSAF
pour l'usage de freelances, puis applique la TVA sur ce montant
augmenté -- pas sur le HT brut. La répartition entre comptes reste
basée sur le HT seul, inchangée.
- field_cotisation_active (case à cocher, "Entrée client" uniquement,
cochée par défaut sur une nouvelle ligne -- optionnelle puisque tous
les devis ne l'incluent pas historiquement).
- field_cotisation_urssaf ("1,1%", montant calculé HT × 1,011, jamais
éditable).
- Montant TTC = round(cotisation × (1 + TVA/100), 2) quand la
cotisation s'applique, round(HT × (1 + TVA/100), 2) sinon --
inchangé pour tous les types hors "Entrée client".
Migration (update hooks 8006/8007) : toutes les lignes "Entrée client"
de field_tva rétro-calculées par figli_compta_ledger_update_8005()
étaient fausses dès que la cotisation s'appliquait (taux mixte HT->TTC,
pas le vrai taux de TVA) -- corrigées avec la même prudence que la
migration précédente : 2021 laissée de côté (pratique non confirmée
sur cette année), et par ligne, un taux déjà "propre" (0/2,1/5,5/10/20
%) signifie qu'aucune cotisation n'a été appliquée (laissé tel quel) ;
sinon le nouveau taux recalculé n'est retenu que s'il retombe lui-même
sur un taux officiel, sinon la ligne reste inchangée plutôt que de
deviner. Montant TTC historique jamais réécrit, comme pour toute
migration de ce module. Résultat : 154 lignes corrigées (cotisation
active), 90 déjà correctes (cotisation inactive), 40 ambiguës laissées
telles quelles, 43 lignes 2021 ignorées.
Formulaire réorganisé en grille à 4 colonnes (HT | 1,1% | TVA | TTC),
case à cocher masquée hors "Entrée client" via #states réel (fiable
ici, contrairement au select TVA -- watch sur field_type_ligne, un
vrai champ, pas un élément ajouté à la main). Colonne "1,1%" ajoutée
au grand livre entre HT et TVA.
HT/TTC passent de "Montant HT"/"Montant TTC" à "HT"/"TTC" (5% de large
au lieu de 6,2%), avec une nouvelle colonne TVA entre les deux (3,5%,
affichée en %, arrondie à 2 décimales -- les lignes migrées portent
parfois un taux rétro-calculé à 4 décimales, voir
figli_compta_ledger_update_8005()). field_tva était déjà exposé sans
changement de requête JSON:API (pas de sparse fieldset ici).
Piège de spécificité CSS : .amount:not(.compte-col) (déjà en place
pour Écart) compte comme 2 classes à cause de :not(), donc un simple
.figli-ht-col à une classe perdait systématiquement contre elle --
recombiné en .amount.figli-ht-col pour égaler/dépasser sa spécificité.
Le champ TVA devient un select (0 %, 2,1 %, 5,5 %, 10 %, 20 %) plutôt
qu'une saisie libre, avec une option "Autre (préciser)" qui révèle le
champ décimal existant -- indispensable pour les lignes migrées, dont
le taux rétro-calculé (figli_compta_ledger_update_8005()) est souvent
un taux effectif non standard qu'il ne faut surtout pas forcer à
s'aligner sur l'une des 5 valeurs officielles.
Montant TTC passe de readonly à #disabled : non focusable, non
éditable même via les flèches d'un input number, et Form API rejette
toute valeur soumise malgré tout au profit de #default_value.
Le select est un élément de formulaire à part (pas un widget de champ)
pour éviter que WidgetBase::extractFormValues() ne s'étouffe sur une
clé étrangère mêlée aux valeurs de field_tva -- un #validate callback
recopie la valeur choisie dans field_tva au moment opportun (après
tous les #validate, avant la reconstruction de l'entité au submit).
L'affichage/masquage du champ "Autre" repose sur du JS simple plutôt
que sur #states : #states pose bien l'attribut data-drupal-states mais
ne bascule jamais la visibilité dans cette modale AJAX précise, pour
une raison non identifiée (un #states pourtant fonctionnel existe
juste au-dessus, sur field_entree_liee, qui surveille un vrai champ
Field API plutôt qu'un select ajouté à la main).
Architecture : Montant HT reste saisi à la main, un nouveau champ TVA
(%) le complète, et Montant TTC = round(HT * (1 + TVA/100), 2) devient
une valeur calculée plutôt que saisie -- champ readonly dans le
formulaire (aperçu live en JS), calcul faisant foi côté serveur dans
figli_compta_ledger_node_presave().
Migration (update hooks 8004/8005) : Montant TTC n'est **jamais**
modifié pour les données historiques -- seul un TVA effectif est
rétro-calculé depuis HT/TTC existants et ajouté en tant que nouvelle
métadonnée (1498 lignes renseignées, 58 laissées vides faute de TTC
source). Précision du champ TVA fixée à 4 décimales après vérification
empirique sur les 1556 lignes existantes : reconstruire TTC = HT * (1 +
TVA/100) avec un taux arrondi à 4 décimales ne s'écarte du TTC réel que
pour 9 lignes (probables factures multi-taux), contre 74 à 2 décimales.
Garde-fou supplémentaire dans le presave : le recalcul du TTC ne se
déclenche que si HT ou TVA ont réellement changé par rapport à la
révision précédente (comparaison à $node->original) -- sans ça,
rouvrir une ancienne ligne migrée pour corriger un simple libellé
aurait silencieusement dérivé son TTC historique de ±0,01€ à cause de
l'arrondi du taux rétro-calculé, ce qu'interdit la règle du projet de
ne jamais corriger les données historiques.
Réorganisation du formulaire : N° Facture rejoint Client sur une même
ligne, Montant HT/TVA/Montant TTC forment la ligne suivante -- les
poids de champs doivent rester des entiers (Drupal tronque silencieusement
tout poids fractionnaire lors de la sauvegarde de l'affichage).
Même modèle multiselect/OR que les filtres Compte et Type déjà en place,
en complément (pas en remplacement) du simple on/off "Signalées
uniquement" du grand livre. Sur /dashboard/compte, la liste d'options se
limite aux tags réellement présents pour le compte sélectionné, et se
réinitialise au changement de compte.
1. "Entrées sur-versées" and "Versements sans entrée liée" tables get a
solde footer, same convention as "Reste à verser" above them --
reuses the totalSurVerse/totalNonLies computeds already backing the
summary cards, no new computation.
2. "Répartition de l'activité par type" and "Top clients" keep their
all-time chart, now followed by a small-multiples grid of the same
chart per année. The type breakdown reuses
total_par_type_par_compte_par_annee, a new field on
DashboardStatsController::stats() built from the same répartition-
level rows already fetched for total_par_type_par_compte (no extra
query, just one more level of grouping in the PHP aggregation). Top
clients per année is computed client-side from the same
entreesDuCompte already used for the all-time version, capped to top
5 (not 10) to keep the grid readable.
HBarChart gains a `compact` prop (narrower fixed columns, smaller text)
for use inside the small-multiples cards -- the all-time charts above
keep the full-width layout unchanged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extends DashboardStatsController::stats()'s répartition-level SQL query
to also group by type (not just année/compte), producing a new
total_par_type_par_compte breakdown alongside the existing global one.
Unlike the reconciliation tables on this page (deliberately entrée/
versement only), this chart covers every type touching the selected
compte's répartition, matching what the general /dashboard already
shows for the whole ledger.
Fixed a latent bug the query change would otherwise have introduced:
solde_par_compte_par_annee[année][compte] used to be a 1:1 assignment
because each (année, compte) pair was unique in the old query -- adding
type to the GROUP BY means several rows can now share that same pair,
so it has to accumulate instead of overwrite (verified the accumulated
totals exactly match a query without the type split, so this preserves
existing behavior for the fields already in use).
HBarChart in dashboard-compte.js gained the same colorFor prop
dashboard.js's version already has (existing Top clients usage keeps
its default green, unaffected).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Root cause: .figli-year-bar was position: relative with an explicit
height (set inline by barStyle()), which opts it out of the flex
container's default stretch alignment -- so it rendered flex-start
(top) aligned first, and the top/bottom: 50% from barStyle() only
*offset* that already-top position instead of anchoring an edge to the
middle. Visibly: green (positive) bars piling up near the top instead
of growing upward from the zero line.
Fixed by wrapping each bar in a .figli-year-bar-slot that stays in the
normal flex flow (so horizontal side-by-side layout for two bars/year
still works) and giving the bar itself position: absolute, anchored
against the slot's full-height box -- that's what makes a 50% top/
bottom offset actually mean "the zero line" instead of "50% further
down/up from wherever flex already put it."
Verified geometrically in-browser: bars now straddle the track's
midpoint and extend outward in the correct direction (positive up,
negative down) on both "Entrées vs versements par année" and
"Évolution du solde".
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sum of the Montant column across every flagged line for the selected
compte, styled red when negative -- same convention as the other
reconciliation tables' footers on this page.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fetches field_flag alongside the existing entrée/versement data (same
JSON:API include list, same buildRows() shape as home.js's flags/
hasFlag). Two changes:
- Rows already shown in the reste-à-verser/sur-versé/non-liés tables
get the same amber left-edge accent + tag badges as /lignes when
they're also flagged.
- New "Lignes signalées" section lists every flagged entrée/versement
for the selected compte, including ones that don't appear in any of
the other tables -- a fully-settled line can still carry a flag for
an unrelated reason (e.g. "client injoignable"), which none of the
reconciliation-based tables would otherwise surface.
Verified live against an isolated test node plus two real flagged lines
already present in production data (client had already started using
the /lignes signalement feature) -- both the section and the inline
badges render correctly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
New field_flag: free-tagging taxonomy reference (multi-value,
auto-create) on ligne_comptable, for marking a line with an
unstructured problem description (e.g. "client impayé") that can't be
detected automatically the way the répartition écart already is.
- New "Signalement" column, inline-editable like Client/Facture/Libellé
(comma-separated tags, datalist autocomplete, server-side auto-create
of unknown tags -- same pattern LedgerActionsController already used
for Client, now shared via findOrCreateTerm()).
- New "Signalées uniquement" filter, mirroring "Écarts uniquement".
- Flagged rows get a distinct amber left-edge accent (box-shadow, not
border) so a row that's both in écart and signalée shows both
indicators without one overwriting the other.
- Native node add/edit form gets the field for free via core's
entity_reference_autocomplete_tags widget.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New /dashboard/compte page, one compte associé (freelance) at a time via
a selector. Focused on entree/versement only (not achat/hébergement/
sous-traitant): reuses the same entrée<->versement reconciliation
algorithm as the /lignes badges (a versement can settle several entrées
at once, split equally), aggregated per compte to surface outstanding
"reste à verser", over-paid entrées, and versements with no linked
entrée at all -- plus solde/entrées-vs-versements/top-clients charts for
context.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Restoring several filters at once from the URL hash (client + type)
fired several concurrent loadOlder()/loadNewer()/ensureScrollable()
chains. They raced on the loadingWindow guard, which silently dropped
a call arriving mid-flight instead of queuing it -- indistinguishable
from "nothing more to load", so the sliding window gave up expanding
after one round even when the table was still far from scrollable,
with no scroll events left to ever retry it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two-part fix so multiple people can edit /lignes at once without
clobbering each other, and see each other's changes without a manual
reload -- no Socket.io/websocket infra, just what fits the existing
fetch-based architecture:
1. Polling (POLL_INTERVAL_MS = 8s): pollForChanges() asks JSON:API for
any ligne_comptable changed since the last check (filtering on the
`changed` field -- confirmed live that JSON:API only accepts a raw
Unix timestamp for this, not the ISO string it returns in responses,
silently matching everything otherwise) and mergeChangedRows()
patches matching rows in place via Object.assign (not a `rows`
reassignment, so it doesn't re-trigger the reconciliation-resolution
watcher for routine polls). A row currently being edited is skipped
entirely rather than overwritten out from under an in-progress
keystroke.
2. Optimistic locking: every row now carries its `changed` timestamp,
sent back on every inline edit (updateType/updateField). A new
checkConflict() compares it against the node's actual changed time
before saving and rejects with 409 if they differ -- someone else
saved this exact line in between. On a 409, refreshSingleRow()
re-fetches just that node and patches it in place so the view
self-corrects instead of staying stuck on the stale state that
caused the rejection.
Verified live end-to-end against an isolated temporary test node (not
real data): an external edit correctly appeared in the browser within
one poll cycle with no reload; a save using a stale `changed` value was
rejected with the conflict error, confirmed via direct DB query that it
left the node's data completely untouched, and the view auto-corrected
to show the other edit. Test node and its paragraph fully cleaned up
afterward.
The reste-à-verser/sur-versé badge only showed the word, with the exact
figure available in the hover title -- had to open the drill-down modal
just to see how much. linkStatus() now exposes the computed amount
(montant) alongside kind/detail, and linkStatusLabel() takes the whole
status object to format it inline: "Reste à verser : 440,00 €",
"Sur-versé : 710,00 €", "Lié : 0,00 €" for a fully settled one.
Verified live: all three cases render with the exact figure in both the
main table and the drill-down modal.
The badge on a versement freelance row previously only got the correct
status once someone clicked it -- before that it could show "inconnu"
(or, worse, silently default to "ok") whenever its linked entrée wasn't
in the currently loaded sliding window, which is common: the entrée
that got paid can be dated years before or after the versement itself.
Also fixed a latent bug in the click path itself: toggleEntreeFilter()
passed the *entrée's* id to loadEntreeGroup(), which needs an
already-loaded row to find a starting nid for the server-side query --
if that entrée wasn't loaded (the exact case this is all about), the
lookup silently found nothing and did nothing. Split the fetch+merge
logic into fetchGroupFromNid() and let loadEntreeGroup() accept a
fallbackNid (the versement's own, always loaded) to start the
traversal from when the entrée itself isn't available locally --
LedgerStatsController::groupeEntree() finds the same connected
component either way.
New ensureLinkedReconciliationResolved(), triggered by a `rows` watcher
after every load/loadOlder/loadNewer, calls the same server-backed
resolution for every visible linkable row instead of waiting for a
click, deduplicated via resolvedLinkGroups so rows sharing an entrée
don't each trigger their own fetch.
Verified live: the LA MINE versement (own date 2022, linked entrées
dated 2023 and 2025) now shows "⚠ Sur-versé" immediately on page load
with zero clicks, where before it required manually opening the badge
first. Drill-down modal and the "Non liée" -> link-form click path both
still work; no console errors on repeated fresh loads.
Type/Client/Facture/Libellé all swap a span for an <input>/<select> via
v-if -- Vue doesn't focus a newly created element on its own, so the
first click only revealed the field and a second click was needed to
actually type into it. Added a v-focus directive (mounted() calls
el.focus() + el.select(), firing exactly once per v-if true-flip) to
all four.
Verified live: a single click on each of the four editable cells now
moves document.activeElement into the field immediately.
updateField()'s client branch previously rejected any name that didn't
match an existing "Client" term, on the assumption the front-end's
datalist restricted input to known names -- it doesn't, it only
suggests them, so this blocked adding a genuinely new client from the
inline edit even though the content type itself allows it. Now creates
the term (same "autocreate" behavior as a standard Drupal entity
reference autocomplete widget) rather than erroring.
Frontend also appends the newly created name to allClientsList so it
shows up in the filter dropdown/datalist immediately, not just after a
reload picks it up via fetchClientNames().
Verified live: typed a brand-new client name inline, save succeeded,
confirmed the taxonomy term was actually created in the database and
the filter datalist updated immediately -- then reverted the test node
and deleted the test term.
New POST /lignes/{node}/champ endpoint (LedgerActionsController::updateField(),
whitelisted to client/facture/libelle -> field_client/field_numero_facture/
field_notes) mirrors updateType(): skips the répartition invariant check
for this save (client/facture/libellé never touch montant_ht or
field_repartition, so it can only ever leave a pre-existing historical
mismatch as it was, never introduce one), wrapped in the same
skip_validation state flag with a try/finally.
Client resolves the typed text against existing "Client" taxonomy terms
only (same known-names list the toolbar's Client filter already offers
via a datalist) -- a non-match is rejected with a clear error rather
than silently creating a new term from a typo.
Frontend mirrors the existing editingTypeId/startEditType/saveType
pattern exactly, generalized to any of the three fields via a single
{id, field} editingCell state.
Verified live: editing all three fields on a row with a known
répartition écart succeeds (bypass confirmed), an unknown client name
is rejected with a visible error and the display value stays unchanged,
and the database was confirmed clean of test artifacts afterward.
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.
Compte and Type were single-value <select> elements. Converted both to
<select multiple> -- Vue binds those to an array natively, so
ctrl/cmd+click and shift+click just work with no custom JS. Matching
semantics are OR within a filter (any of the selected comptes/types) and
AND across filters, same as the existing client/year/écarts filters.
The URL hash (compte=Maud,Bachir&type=versement,achat) now carries
comma-separated lists instead of a single value, so the shareable/
reloadable filtered view still works with multiple selections.
onScroll() throttled via requestAnimationFrame with a guard
(_scrollRaf) reset from *inside* the rAF callback -- if that callback
ever failed to fire (reproduced via a backgrounded/non-visible tab,
where browsers routinely throttle or suspend rAF), the guard stayed
true forever, silently dropping every future scroll event. Matches
exactly what was reported: the window extends once when scrolling
toward the past, then stops responding to scrolling at all.
detectCurrentYear()/checkEdges() are cheap (DOM reads + early-return
guards, do no real work themselves), so there's nothing worth
throttling here -- removed the rAF entirely and call both directly on
every scroll event. Verified live: repeated scroll-to-top no longer
gets stuck after the first extension, keeps loading older months
across many rounds.
Hébergement (and achat/sous-traitant) were already linkable to an
entrée client -- LINKABLE_TYPES already included them, so the "Lier"
button and the reconciliation math worked fine -- but versementStatus()
hardcoded item.type !== 'versement' and returned null for every other
type, so those rows never got the Non liée/Lié/Reste à verser/Sur-versé
badge at all, and had no way to open the link form except the small
actions-column icon.
Renamed versementStatus()/versementStatusLabel()/versementStatusClasses()
to linkStatus()/linkStatusLabel()/linkStatusClasses() and generalized
the type check to LINKABLE_TYPES.includes(item.type). Verified live: a
hébergement row linked to an entrée now shows the same badge, status
math, and drill-down modal as a versement.
The modal's group is usually 2-4 rows touching only 1-2 comptes -- unlike
the main table (always all 8, so columns line up across every row/year),
showing all 8 there was mostly empty columns. New modalComptes computed
filters allComptes down to whichever actually carry a value somewhere in
filterEntreeGroup.
Also renamed "Entrée + sorties liées" to "Entrées et sorties liées"
(title and footer label) to match multi-entrée groups.
filterEntreeGroup, reconciliationByEntree and versementStatus only ever
searched `rows`, the currently loaded date-range slice -- a versement
linked to entrées from other years (confirmed live: one node had 3
linked entrées spanning 2023/2025/2025) silently only showed whatever
happened to be in the loaded window, both in the drill-down modal and in
the reste-à-verser/sur-versé math.
Added LedgerStatsController::groupeEntree() (GET /lignes/api/groupe/
{node}), a real DB query following field_entree_liee in both directions
(a node's own targets, and any node referencing it) -- something the
client can't discover from a partial window. toggleEntreeFilter() now
fetches the complete group up front and merges whatever isn't already
loaded into groupExtraRows; every affected computed reads the combined
pool via a new allKnownRows.
Also stopped versementStatus from defaulting to a false "ok" when a
linked entrée's reconciliation can't be resolved yet -- it now reports a
distinct "inconnu" (À vérifier) status instead of silently assuming
everything's settled.
fetchClientNames() requested page[limit]=200 but never followed
links.next, and JSON:API silently clamps to a 50-item hard cap -- with
106 client terms, everything past the 50th alphabetically (e.g. "LE
CAMPUS") was dropped. Now loops through every page like fetchLignes()
already does.
The core Navigation module's admin sidebar (#admin-toolbar) was showing
on our custom /lignes, /dashboard, /lignes/historique and lier-entree
pages. Added a route-scoped library (attached only for those route
names in hook_page_attachments(), not folded into the always-on
admin_chrome attachment) so real Drupal admin pages keep the sidebar.
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.
versementStatus() returned null whenever a linked versement's own
compte(s) had no residual against any linked entrée -- meaning a
versement that was linked but fully reconciled got no badge at all,
silently losing the only way to open its drill-down (the badge is
also the click target). Only "not linked" and "has a residual" ever
rendered one.
Now always returns a status for any versement, adding a fourth kind
("ok": linked, no residual on any checked entrée) alongside
non-liee/reste/sur-verse. Renders as a plain "Lié" badge in the
default green (no is-anomalie/is-reste modifier), matching the
convention the entrée side already uses for a fully-settled "N
sorties liées" badge, and stays clickable since item.entreeLieeIds
is still non-empty.
Verified: several previously badge-less linked versements (e.g.
"facture-SC-Bachir-260329B-FIGLI", "F58_260506_FIGLI") now show a
green "Lié" badge, and clicking one opens the same 3-row drill-down
(entrée + both its linked sorties) as before.
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.
#compte=Maud&client=EPAU&type=versement&annee=2023&ecarts=1&aller=2024
-- reload the page or send the link and the same filtered view (or
year jump) comes back.
readHashState()/buildHashString() handle the encoding; syncHash()
writes back via history.replaceState() (not pushState -- tweaking a
dropdown shouldn't spam the back button with history entries), called
from the existing filter watchers plus jumpToYear(). "aller" isn't an
ongoing filter (jumpYearValue always resets to '' right after firing)
so its target year is tracked separately (lastJumpYear) purely for the
hash, and omitted whenever "annee" is also present -- the two describe
overlapping year state and jumpToYear() already clears an active
"Année" filter when it runs, so keeping both would just be redundant.
mounted() applies compte/client/type/écarts unconditionally (they only
narrow filteredRows, safe regardless of load path), then branches:
annee in the hash sets filterYear (triggering enterYearMode() same as
manual use), aller calls jumpToYear() directly, otherwise the default
load()+scroll-to-bottom path runs as before -- each path already
manages its own loading state and scroll position, so only one runs.
Verified all three round-trip through reload: compte+type together
(ensureScrollable extends to find matches, as before), aller=2023
(first fetch targets exactly 2023-01-01..2024-06-30), and annee=2022
(every loaded row's data-year is 2022, filter dropdown reflects it).
Default no-hash load is unaffected.
versementStatus() was reusing reconciliationByEntree()'s aggregate
resteAVerser/surVerse, which sums residuals across every compte the
entrée touches -- including comptes tied to *other* sorties linked to
the same entrée. A versement paid entirely through Maud could show
"reste à verser" driven by an unrelated Sandrine/Chloé shortfall on
the same entrée, or vice versa mask its own compte's sur-versement
behind an unrelated compte's surplus.
reconciliationByEntree() now also keeps a per-compte residual map
(parCompteResidual), and versementStatus() sums only the residuals for
the compte(s) this specific versement's own répartition touches.
Verified against a real case: entrée EPAU F2549-50-51 (répartition
across 8 comptes) with one linked "Versement Maud" of -10 000€ against
an entrée-side Maud share of 5 632,86€. The entrée's own badge still
correctly shows the aggregate ("reste 24 325,54 € · sur-versé
4 367,14 €"), but the versement row itself now shows "Sur-versé :
4 367,14 €" -- its actual Maud-only residual -- instead of the
previous "Reste à verser", which was purely an artifact of the other
7 comptes' unrelated shortfalls.
Clicking an entrée's "N sorties liées" badge drills down to just that
entrée + its linked sorties (filterEntreeId) -- but the footer kept
showing the current year's totals throughout, which answers a
different question than the one this view exists for ("does this
entrée balance against what was paid out").
drilldownTotals() computes the same shape locally (montant_ht/ttc,
écart, par_compte) from the already-loaded drill-down rows -- no
server round-trip needed, unlike the per-year figures. footerTotals()
picks between it and currentYearTotals depending on whether
filterEntreeId is set, so the footer swaps automatically and reverts
the moment the drill-down is cleared.
Verified: montant_ht and every compte column match the drill-down's
two rows summed by hand exactly (to the cent), and closing the
drill-down correctly restores the normal per-year footer.