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