Phase 1 du chantier filtrage serveur de /lignes : l'écart devient une
vraie valeur stockée plutôt que recalculée à la volée en résolvant les
paragraphes de répartition, pour que le futur filtre "Écarts uniquement"
côté serveur puisse filtrer directement dessus.
Contrairement au Montant TTC (protégé par un garde-fou pour ne jamais
altérer un TTC historique), l'écart n'a pas de valeur passée à
protéger -- il reflète l'état *actuel* de la répartition, donc
recalculé sans condition à chaque sauvegarde. figli_compta_ledger_
node_presave() calculait déjà cette somme pour la validation
répartition == HT ; le stockage était quasi gratuit à ajouter au même
endroit.
Migration (update hooks 8009/8010) : rétro-calcul mécanique pour les
1556 lignes existantes (tous types, contrairement aux migrations TVA/
cotisation qui ne concernaient que les entrées client) -- aucune
ambiguïté à arbitrer, juste HT moins répartition. Vérifié
indépendamment ligne par ligne après coup : 0 écart entre la valeur
recalculée à la main et celle stockée par la migration.
Nouveau flag d'état 'figli_compta_ledger.skip_revision', utilisé
uniquement par cette migration : backfiller un champ purement calculé
sur 1556 lignes déjà migrées n'est pas un changement éditorial qui
justifie 1556 nouvelles révisions -- vérifié qu'aucune révision
supplémentaire n'a été créée. Reste indépendant de skip_validation
(déjà utilisé par tous les autres scripts de migration de ce module,
et qui doit continuer à créer une révision).
Restructuration de figli_compta_ledger_node_presave() : le calcul de
l'écart et son stockage se font maintenant même sous skip_validation
(seul le lancement de l'exception reste conditionnel) -- vérifié que
l'exception se déclenche toujours normalement sur une vraie
répartition incohérente, et que Montant TTC/Cotisation restent
inchangés sur une sauvegarde qui ne touche ni HT ni TVA.
- Gin's .layout-container margin-left/right (48px) réduit à 16px, sur
les routes de ce module (hide_admin_chrome, déjà route-scopé).
- #figli-home-app margin-bottom réduit de 1rem à 0,33rem.
- .figli-table-wrap : max-height 75vh remplacé par calc(100vh - 16rem)
-- le pourcentage de viewport ne tenait plus compte du titre/menu +
barre d'outils au-dessus (hauteur à peu près fixe, ~16rem), qui a
grandi au fil des filtres ajoutés depuis le dernier fix du double
scroll -- avait fini par redépasser ce que 75vh laissait de marge,
réintroduisant un scroll de page en plus de celui du tableau (84px
d'écart mesuré avant ce correctif, ~8px après -- l'essentiel de
l'écart restant venant justement de la marge du dessous réduite
ci-dessus, pas d'un nouveau débordement).
Le point ambre discret n'était pas assez visible. Remplacé par un
border-bottom épais (3px, rouge) directement sur la cellule TVA --
lisible d'un coup d'œil sans avoir besoin de place horizontale
supplémentaire, contrairement à un badge texte/icône dans une colonne
de 3,5% de large.
- 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.
Le menu Grand livre/Dashboard/Par compte passe en position fixe,
aligné avec le <h1> de la page plutôt que sur sa propre ligne en
dessous -- les deux viennent de régions Drupal différentes (le titre
du bloc sticky top-bar de Gin, le menu du contenu de la page) sans
conteneur flex/grid commun pour les aligner autrement.
Ça ne suffisait pas à éliminer le scroll de page en plus de celui du
tableau (max-height: 75vh sur .figli-table-wrap) : Gin applique un
margin-bottom: 80px sur <main class="page-content">, pensé pour une
page d'admin classique, pas pour ce layout à hauteur de viewport fixe.
Neutralisé sur les routes du module (hide_admin_chrome, déjà route-
scopé).
Au passage, corrige un oubli : figli_compta_ledger.dashboard_compte
n'était jamais dans la liste hide_admin_chrome, donc la barre d'admin
Gin restait visible sur /dashboard/compte (et donc le titre plus bas
que sur les 3 autres pages) -- ajouté.
Et sur demande complémentaire en cours de route : le même menu manquait
purement et simplement sur /lignes/historique (pas de template Twig
propre, juste un tableau brut) -- ajouté en tableau de rendu directement
dans HistoryController, mêmes classes CSS que le <nav> des autres pages.
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é.
- TVA par défaut passe de 0 % à 10 % (taux intermédiaire) pour une
nouvelle ligne -- la plupart des lignes saisies portent effectivement
de la TVA.
- Montant TTC (désactivé) était illisible : Chromium affiche le texte
d'un champ disabled via -webkit-text-fill-color plutôt que color,
qui restait sur son gris par défaut du navigateur -- quasi invisible
sur le fond gris de --flform-bg-subtle en mode sombre. Fixé
explicitement sur les deux thèmes.
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).
- Le select "Type de ligne" affichait un chevron géant répété (le
shorthand `background: transparent` sur input/select/textarea
réinitialisait aussi position/repeat/size de Claro, transformant sa
flèche unique alignée à droite en motif carrelé). Les select gardent
maintenant leur background Claro intact.
- Les menus "Toggle Actions" (⋮) de la Répartition étaient ouverts en
permanence : mon display:flex écrasait le display:none par défaut de
Claro, cassant le toggle piloté par paragraphs.actions.js. Rescopé à
`.paragraphs-dropdown.open .paragraphs-dropdown-actions`.
- Marges resserrées : le `.form-item { margin-block: 1.5rem }` de Claro
s'ajoutait à notre propre grid-gap. Neutralisé (`margin-block: 0`), et
gap/paddings réduits.
Le formulaire natif Drupal (Menu settings, URL alias, Authoring
information, tabledrag/drag-handle pour des tableaux de 1-3 lignes,
"Title" affiché tel quel) était pensé pour un éditeur de contenu
générique, pas pour la saisie numérique quotidienne d'une ligne
comptable. Sans toucher au Form API (validation, structure des champs
inchangées) :
- Grille compacte à 3 colonnes (Date+Type, Client, Montant HT/Facture/
TTC en ligne), au lieu de l'empilement vertical par défaut.
- Menu settings / URL alias / Authoring information / Published
masqués via #access -- jamais utilisés pour une ligne_comptable.
- "Title" relabellisé en "Libellé court" avec une description qui
explicite son rôle de repli quand "Notes / détail" est vide (déjà le
comportement de home.js/dashboard*.js, jusque-là invisible côté
formulaire).
- Répartition / Entrée liée : poignée de glisser-déposer et bascule
"Show row weights" masquées (l'ordre n'affecte jamais la somme ni
l'affichage), boutons Ajouter/Retirer/Dupliquer restylés.
Classe CSS partagée `figli-ledger-form` ajoutée par form_alter plutôt
que de cibler la classe générée par Drupal, différente entre le
formulaire d'ajout (node-ligne-comptable-form) et celui d'édition
(node-ligne-comptable-edit-form).
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>
The native <input type="date"> widget's displayed digit order follows
the browser's own locale, not Drupal's site language -- an English-
locale browser was showing 07/26/2024. Switches the date sub-element to
'text' with an explicit PHP date format so Drupal parses/displays it
consistently for everyone.
Verified end-to-end (not just cosmetically): submitted 05/03/2024 through
the real add form and confirmed it stored as 2024-03-05 (5 March), not
2024-05-03 -- genuinely parsed as day/month, not silently reinterpreted
as month/day.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Distinguishable from the default badge green (used for "Lié") while
staying green rather than orange -- it's an amount owed, not a warning.
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>
Root cause: the main table used auto layout, so every loadOlder()/
loadNewer() reflowed every column's width based on whatever was
currently loaded -- visibly shifting the table, spilling past the
viewport into a horizontal scrollbar, and corrupting loadOlder()'s
scroll-position compensation (which assumes the scrollHeight delta
after prepending rows is *only* the new rows' own height -- not true
once existing rows also reflow). That's what made scrolling up feel
like it kept snapping back down.
- table-layout: fixed with explicit per-column widths (percentages
throughout, not mixed with rem -- mixing meant the rem columns' width
was added on top of the percentage budget instead of coming out of
it), plus box-sizing: border-box so padding doesn't inflate columns
beyond their declared width.
- overflow-x: hidden instead of auto on the scroll container: with both
x and y auto on the same element, the browser has to guess whether a
vertical scrollbar will appear before laying out width: 100%, and a
wrong guess understates available width by a scrollbar's worth --
exactly enough to tip a tightly-fitting table into needing horizontal
scroll too.
- Fixed a footer-row column count bug found along the way: it still had
two actions-col cells and a colspan=5 label from before the link
button was removed, leaving the label 6 columns short of Signalement
and misaligning every footer cell after it.
Verified with a 25-round scripted scroll-up stress test spanning 3
loadOlder() triggers: scroll position stays correctly anchored near
where the user was looking, never jumps toward the bottom.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Remove the left-side link button (actions-col) -- the link status
badge already does the same thing (view linked entrées, or open the
link form when unlinked), so the button was pure duplication.
- Remove the red row outline on écart lines -- the Écart column itself
(bold red text) already flags it, no need for a second row-level cue.
- Hide the link status badge specifically on "Hébergement" lines (both
the main table and the drill-down modal) -- reconciliation math is
untouched (hébergement sorties still count toward other rows' totals),
this only suppresses the badge on hébergement's own row.
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.
Facture and libellé rendered their span with no fallback text when
empty, unlike Client's existing "|| '—'" -- an empty span has zero
width/height, so there was nothing there to click at all. Added the
same "—" placeholder to both.
Also switched .figli-editable-cell to display: block with a min-height,
so the clickable area fills the whole cell's content box instead of
just hugging the text -- a lone "—" character was still a tiny target
even with a placeholder.
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.
Several entrées for the same client often share the same title format
("EPAU - 2026-01-05"), so being able to type the invoice number itself
is what actually finds the right one quickly. buildEntityQuery()
withholds $match from the parent call (which would otherwise add its
own title-only condition) and applies it manually as an OR across title
and field_numero_facture instead.
Verified live: searching "F2549" (not present in the title at all, only
in field_numero_facture) now correctly surfaces that entrée; a
title-only search and a no-match search both still behave as before.
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.
Editing a ligne_comptable with an inconsistent répartition correctly
blocked the save (setErrorByName() on field_repartition), but the
message text had nowhere to render: it normally goes through the page's
status-messages region, which isn't part of this standalone AJAX
ReplaceCommand fragment. The form re-render did mark every répartition
row with a subtle red outline, but with no visible explanation the modal
just looked stuck.
figli_compta_ledger_node_form_ajax_submit() now also emits a
MessageCommand for each form error, mirroring the pattern already used
for the success path's messenger() messages. Verified live: breaking a
répartition sum and clicking Save now shows a proper error toast with
the exact mismatch amount, the modal correctly stays open, and the node
is confirmed unchanged in the database.
A native <select multiple> can't collapse to a single-line trigger and
open as an overlay on hover -- it's either an always-open multi-row
list box or a single-value dropdown, not both. Replaced with a custom
dropdown: a trigger showing the current selection ("Tous" / "Maud,
Ouidade"), and a checkbox list shown on hover via a plain CSS :hover
rule (no open/close state needed). Checkboxes instead of ctrl/shift+click,
but same result -- several comptes/types at once -- with a more
discoverable interaction (no keyboard modifier to know about).
Also added a "✕ Effacer ce filtre" button next to Compte/Client/Type/
Année, shown only once that filter is active. Directly addresses the
Client filter specifically being painful to clear (select the text,
delete, press enter) -- one click now.
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.
It was the only versement status that did nothing on click -- every
other kind opens the drill-down modal. Now it opens the same "Lier à une
entrée client" form as the actions-column link button, since that's
exactly what a non-liée versement needs next.
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.
field_entree_liee is multi-value, but the single #tags entity_autocomplete
rendered it as one comma-separated text box -- functionally correct but
easy to mistake for "only one entrée can be linked". Rebuilt as one
autocomplete field per link plus an "Ajouter une référence" button (the
classic Drupal multi-value pattern, matching the Répartition paragraphs
widget already used elsewhere in this form). Clearing a field's text
drops that link on save, rather than a "remove" button -- a remove
button would need to shift every later delta, which fights Drupal's own
value-restoration-on-AJAX-rebuild (verified live: that restoration only
works cleanly for pure appends at a stable delta, which is all
addItem() does).
Verified end-to-end via raw AJAX POSTs against a real 3-entrée versement:
initial load pre-fills 3 separate fields, "Ajouter" appends a 4th empty
one without disturbing the first 3, and submitting with one field
cleared and a new one filled saves exactly the expected set.
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.
.top-bar was unconditionally hidden on every page (admin_chrome library)
on the assumption it always renders empty, which held on our own pages
but not on entity edit forms, where Gin puts the page's local actions --
Save included -- inside .top-bar. Folded it into the same route-scoped
library as the admin sidebar (renamed hide_admin_sidebar ->
hide_admin_chrome) so both are hidden only on our front-end routes and
real admin pages keep a working Save button.
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.