33 Commits
Author SHA1 Message Date
bachir 6e1c421e5b ecarts de 0.01€ visible dans le tableau 2026-09-09 21:42:17 +02:00
bachir d2f3179f01 Import de relevé bancaire CSV en libre-service (/lignes/importer-releve)
Chaque transaction du relevé devient une ligne « à trier » : type autre,
répartition vide (liseré rouge existant via field_ecart), tag field_flag
« IMP AAMMJJ » (liseré ambre + filtre existants), client rapproché par
mots (ClientMatcher : séquence contiguë, sinon mot significatif unique --
jamais en sous-chaîne, jamais auto-créé).

- src/Import/ : CsvReleveParser (ISO-8859-1 confirmé, en-tête strict,
  fgetcsv avec escape '' explicite -- dépréciation PHP 8.4, rejets
  propres avec n° de ligne), ReleveImportBatch (Batch API par lots de
  25, dédoublonnage COMPTÉ par empreinte field_import_fitid -- max(0,
  k−m) importe les vrais doublons légitimes et dédoublonne à travers
  des fichiers qui se chevauchent --, totaux de contrôle au centime
  sur la page de résultat, résumé en tempstore privé).
- ReleveUploadForm : upload private://releves (fichier conservé +
  usage, hors de portée du cron), parse en validateForm(), batch,
  redirection vers la page de résultat.
- field_montant_releve : référence bancaire immuable, écrite une fois
  à l'import et jamais par presave ; affichée en TEXTE sous Montant
  TTC (widget remplacé par un #type item -- un item ne soumet rien et
  extractFormValues() saute le champ sans valeur soumise, la valeur
  survit donc à chaque save) ; masquée sur les lignes sans montant.
- SkipValidationContext : contournement du contrôle de répartition
  requête-scopé (ferme le trou de concurrence de l'ancien state
  global, AUDIT-2026-09-09 §2.2) -- presave honore le service (state
  gardé pour compat), updateType/updateField basculent dessus.
- Permissions (AUDIT §2.2 priorité 1) : access figli ledger sur toutes
  les routes du module + autocomplete (RouteSubscriber), import
  réservé Éditeur/Admin, access content retiré du rôle Authenticated
  (config/sync re-exportée pour les 4 rôles).
- Gin : hook_gin_ignore_sticky_form_actions() -- sans ça, le bouton
  Importer partait dans la barre sticky du chrome masqué.
- install : 8011 champs, 8012 index sur les empreintes, 8013/8014
  montant_releve sur le formulaire sous le TTC (poids renumérotés).
- /lignes : boutons + Ajouter / Importer / Historique dans le footer
  sticky (compacts), footer colspan dès la première colonne, badges de
  signalement qui reviennent à la ligne au lieu de déborder.
2026-09-09 14:58:36 +02:00
bachir 74d8eb2aa2 Ajoute "Charges structurelles par client" (+ par année) sur le dashboard SAS
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".
2026-09-08 15:30:31 +02:00
bachir 96bd9502d2 Les totaux du footer de /lignes suivent maintenant les filtres actifs
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).
2026-09-08 15:25:18 +02:00
bachir 8dfb4af98a Nouvelle page /dashboard/repartition, renomme "Dashboard" en "SAS" dans le menu
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).
2026-09-08 15:00:22 +02:00
bachir 3552b5a79d Ajoute la répartition par type et le top clients par année sur /dashboard
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).
2026-09-08 14:34:04 +02:00
bachir f7e7ae3265 Phase 2 : filtrage server-side de /lignes (compte, client, type, signalement, écarts, recherche libre)
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.
2026-09-08 14:04:35 +02:00
bachir 0baa03855b Le champ/colonne "1,1%" affiche le delta seul, pas HT + 1,1%
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.
2026-09-07 14:38:10 +02:00
bachir 134c97ac9d Supprime le double scroll : menu à hauteur du titre, marge Gin retirée
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.
2026-09-07 12:46:36 +02:00
bachirandClaude Sonnet 5 11250bea77 Add solde footers + per-année small multiples to /dashboard/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>
2026-09-06 20:41:11 +02:00
bachirandClaude Sonnet 5 ec2af0ea7e Add "Répartition de l'activité par type" chart to /dashboard/compte
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>
2026-09-06 20:35:10 +02:00
bachirandClaude Sonnet 5 34b1082991 Add signalement (flag) tags for problem lines on /lignes
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>
2026-09-06 14:55:46 +02:00
bachirandClaude Sonnet 5 ceaf301bf0 Add per-compte dashboard: reste à verser between entrées and versements
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>
2026-09-06 14:29:27 +02:00
bachir 71fde0f3c0 Add live polling and optimistic locking for concurrent inline edits
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.
2026-09-06 12:49:56 +02:00
bachir 0efc41f121 Create a new Client term on the fly instead of rejecting unknown names
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.
2026-09-06 11:11:37 +02:00
bachir 11eb9cadb0 Make Client/Facture/Libellé editable in place, same as the type badge
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.
2026-09-06 10:46:30 +02:00
bachir 50a6432691 Search field_numero_facture too in the entrée-liée autocomplete
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.
2026-09-06 10:37:06 +02:00
bachir 6c0bddd964 Replace the comma-separated tags field with one field per linked entrée
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.
2026-09-05 19:19:08 +02:00
bachir b73a980282 Resolve the full entrée/versement group regardless of the sliding window
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.
2026-09-05 19:03:41 +02:00
bachir 0314625593 Client autocomplete, drill down from versements, multi-entrée linking
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.
2026-09-05 11:35:18 +02:00
bachir ef2d7b801d Add sous-traitant, salaire/stage, and charges local pro ligne types
Three new field_type_ligne values (config/sync +
figli_compta_ledger.install for fresh-install parity), wired through
every place that enumerates the type list: LedgerActionsController's
inline type-change endpoint, home.js's type dropdown/badge, dashboard.js's
per-type chart, home.css's badge colors.

Sous-traitant is linkable to field_entree_liee (pays out against a
client's work, like versement/achat); salaire/stage and charges local
pro are not (structural costs, like charge). Turns out 13 lines
already carried "salaire_stage" and 1 "sous_traitant" as raw field
values from the historical migration -- list_string doesn't enforce
allowed_values at the storage level, so they saved fine but had no
label and weren't selectable in the UI until now.

Also fixes a real bug this surfaced: filtering /lignes by a sparse
type (e.g. "Autre") silently broke the sliding window -- so few rows
matched that the table no longer overflowed, so it never fired another
'scroll' event, so loadOlder()/loadNewer() never ran again ("les lignes
antérieures ne chargent plus"). Added ensureScrollable(), which keeps
extending the window in both directions whenever a thinning filter
(compte/client/type/écarts) leaves too little to scroll, and widened
the trim cap while filtering (MAX_LOADED_MONTHS_FILTERED) since the
normal 30-month cap actively fights a sparse filter -- extending one
end and immediately trimming the other nets out to nearly the same
slice every round. Bounded by a round counter rather than "did the
window stop moving": addMonths() uses Date#setMonth(), which isn't
invertible for month-end dates, so the window can drift indefinitely
in tiny steps without ever exactly repeating.

Verified: filtering by "Autre" now finds 24 matches (was stuck at 1)
and the view becomes scrollable within ~17s, settling cleanly rather
than hanging.
2026-09-05 10:26:47 +02:00
bachir 2f9ad7d52c Rebuild /dashboard with charts, add Grand livre/Dashboard nav
Nav: a small "Grand livre" / "Dashboard" switcher, top-right on both
pages, with the current page highlighted. Needed hook_theme() to
declare current_route as an accepted variable for both theme hooks --
same class of bug as the earlier can_view_history fix: an undeclared
variable passed via the render array is silently dropped rather than
reaching Twig.

Dashboard: replaced the old "solde par compte / par client" tables
(computed client-side from a full unwindowed JSON:API fetch of every
node -- the same performance problem the /lignes sliding window was
built to avoid, just not yet felt at 1500+ lines) with a single
aggregate endpoint (DashboardStatsController, plain SQL GROUP BY) and
five chart panels: CA par année, solde par compte (diverging,
red/green), solde par compte trend (small multiples per compte),
répartition par type, top clients par CA. No charting library --
small dependency-free div/CSS bar charts (HBarChart/ColumnChart/
MiniTrend components in dashboard.js), consistent with this project
vendoring its own JS.

Two data-correctness fixes along the way: (1) the historical stray
mistyped dates (0213-06-15, 2015-08-29 -- preserved as-is per this
module's policy) needed excluding from per-year buckets without
excluding their money from all-time totals, so the filtering happens
per-output-field in PHP rather than as a blanket SQL date range. (2)
PHP silently casts numeric-looking array keys ("2021") to actual
integers, so array_keys() on a year-keyed map produces a mix of ints
and strings -- json_encode emits the int ones as bare JSON numbers in
a list (unlike object keys, which JSON always stringifies), which
broke the frontend's annees[i].slice(2) trend-card labels. Fixed with
an explicit array_map('strval', ...).

Verified: bar widths/colors match the underlying data exactly (e.g.
EXT.'s red bar is proportionally sized against Maud's green one per
their actual solde ratio), all 8 trend cards render correct year
labels, and both nav links correctly highlight on their own page.
2026-09-04 23:30:40 +02:00
bachir 3ec894c167 Don't block type changes on a pre-existing répartition écart
updateType() only ever touches field_type_ligne (and possibly
field_entree_liee) -- never montant_ht or field_repartition -- so it
can't introduce a new répartition mismatch, only leave an existing
one (preserved from historical data, per the module's own docblock)
untouched. Wrap the save in the same skip_validation state flag the
migration scripts use, scoped tightly with try/finally so it always
clears even if save() throws for an unrelated reason.

Verified: changing the type of a line with a known -0.02€ écart now
succeeds, the écart is unchanged afterward (montant_ht and the
répartition sum both identical to before), and the state flag reads
back unset once the request completes.
2026-09-04 22:02:58 +02:00
bachir f260e8a605 Inline-edit the type badge directly in the table
Clicking a row's type badge swaps it for a native <select> in place;
picking a new value POSTs to a new endpoint
(LedgerActionsController::updateType) instead of opening the full
edit modal for this one field.

The endpoint goes through the normal node save() lifecycle, so
figli_compta_ledger_node_presave() still forces a proper revision and
still enforces the répartition invariant -- nothing here bypasses
that. It also clears a stale field_entree_liee when the new type is
no longer linkable (versement/achat/hébergement), mirroring the full
form's #states visibility rule. CSRF-protected via core's own
/session/token, scoped to CsrfRequestHeaderAccessCheck::TOKEN_KEY to
match what that endpoint actually generates. Verified end-to-end via
the real click flow: correct revision (user + timestamp), correct
optimistic UI update, correct field_entree_liee clearing, and 400/403
on invalid type / missing CSRF respectively.
2026-09-04 21:57:33 +02:00
bachir 5afc5905d7 Include ouverture lines in the footer's year totals
totauxAnnee() excluded ouverture lines, so the footer showed net
movement for the year rather than the actual account balance -- it
never matched the "clôture calculée" (ouverture + movement) that the
ouverture/clôture reconciliation badge's tooltip cites for the same
year, which was confusing side by side. Verified per compte: ouverture
2023 minus the reconciliation écart now equals the footer's 2022
total exactly, to the cent, for every compte.
2026-09-04 21:25:53 +02:00
bachir 7eabcc6bd8 Flag ouverture/clôture discrepancies between consecutive years
Each year's ouverture (opening balance) should match the previous
year's calculated closing balance (that year's own ouverture + every
movement dated within it). The historical spreadsheets carry real
gaps here that were preserved as-is during migration -- this surfaces
them per compte, on the ouverture row(s) they affect, the same way
the per-ligne écart column already does, rather than correcting them.

New LedgerStatsController::reconciliationOuverture() endpoint (a
single grouped SQL aggregate over ouverture vs. non-ouverture lines
per year/compte, not per-node loading) backs a small badge shown only
on the affected ouverture row(s), scoped to whichever compte that
specific row's répartition touches.
2026-09-04 21:21:57 +02:00
bachir b6358a7258 Sliding-window loading for the grand livre + year-scoped sticky footer
With 5+ years of migrated history, loading every ligne up front took
~40s. The table now only ever holds a date-range window (~18 months
around today by default), extended by 6 months when scrolling near
either edge and trimmed from the far end past a 30-month cap. The
totals footer and "Année" filter can't be answered from a partial
window, so they're backed by two new small endpoints
(LedgerStatsController) instead: per-compte totals for whichever year
is currently scrolled into view, and the distinct list of years with
data.
2026-09-04 16:32:42 +02:00
bachirandClaude Sonnet 5 0e36274d51 Show montant HT + notes in the entrée client autocomplete
Several entrées for the same client often share the same title
format ("EPAU - 2026-01-05"), which isn't enough to tell them apart
when linking a sortie. Append the amount and the notes/invoice
reference to each suggestion's label -- verified end to end (real
autocomplete HTTP request, then an actual save) that the richer label
still parses back to the right node id.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 12:33:08 +02:00
bachirandClaude Sonnet 5 d04d0e8824 Link sorties to the entrée client they pay out against
New field_entree_liee (entity reference, node -> node) on
ligne_comptable, restricted to entrée-type nodes via a custom
EntreeClientSelection plugin -- narrows further to the same client as
the sortie being linked when one is already known, using the
referencing-entity context Drupal's selection handler API passes
through (getSelectionHandler($field, $entity)).

Quick-link UI (per the associates' explicit ask: no need to open the
full ligne_comptable form just for this):
- A chain-link icon next to the edit pencil on versement/achat/
  hébergement rows (the only types that pay out against a client
  invoice) opens LinkEntreeForm, a one-field AJAX modal, reusing the
  same modal/close-on-save plumbing as the edit form. Filled/colored
  when already linked, with the linked entrée's label on hover.
  Also present (states-hidden unless one of those three types is
  selected) on the full node form for whoever's already there anyway.
- Entrée rows get a reconciliation badge once at least one sortie
  links back to them, clickable to drill the table down to just that
  entrée and its linked sorties.

Conformity check assumes multi-compte répartition on both sides (an
entrée's répartition and each linked sortie's répartition can each
split across several comptes -- confirmed this is the real shape of
"hébergement" sorties, e.g. OVH/HETZNER renewals split across all 8
comptes, even though versement/achat lines happen to always be
single-compte in the current data). Per compte, compares the entrée's
répartition share (positive, owed) against the summed répartition of
every linked sortie (negative, paid) -- a residual near zero means
settled, positive means still owed ("reste à verser"), negative means
overpaid ("sur-versé", flagged for a closer look).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 12:22:47 +02:00
bachirandClaude Sonnet 5 27a799e937 Lock down the generic entity autocomplete route to logged-in users
Found while adding field_entree_liee's autocomplete: core's
system.entity_autocomplete route is '_access: TRUE' by design --
EntityQuery::accessCheck() only enforces node grants (published
status), not the base 'access content' permission, so it doesn't
respect this site's login-only lockdown the way every other route
does. Verified anonymously via plain curl that the *existing*
field_client (client taxonomy) autocomplete leaked real client names
the same way -- this isn't specific to the new field, it's a gap in
any entity reference autocomplete on the site.

hook_route_alter() doesn't exist anymore in this Drupal version
(routing moved to an event-based RoutingEvents::ALTER subscriber) --
a first attempt using the procedural hook silently did nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 12:22:26 +02:00
bachirandClaude Sonnet 5 384b283547 Mandatory revisions on ligne_comptable + cross-node history page
Every save now forces a new revision, unconditionally:
- Form: hide the "Create new revision" checkbox and the log message
  field (#access = FALSE, not just a default) so submitted values for
  either can't override them -- Form API discards user input for
  #access-denied elements and falls back to #default_value.
- hook_node_presave(): the same thing enforced for any save that
  doesn't go through the form (drush scripts, etc.), plus explicitly
  setting the revision author (current user) and revision timestamp.
  setNewRevision(TRUE) alone does NOT refresh revision_timestamp -- it
  carries over the previous revision's value, which would silently
  mislabel every edit with its predecessor's save time. Verified via a
  drush test save before/after.
- Applies regardless of figli_compta_ledger.skip_validation: that flag
  is about the répartition-sum check on historical imports, a
  different concern -- revision history is never exempted.

New /lignes/historique page (HistoryController): a single reverse-
chronological feed across every ligne_comptable's revisions, gated by
the 'view ligne_comptable revisions' permission (granted to all three
associate roles). Conceptually a revision of one ligne is a revision
of the grand livre as a whole, so this aggregates across nodes rather
than reusing Drupal's per-node revision history page. Each row links
to that specific revision via core's existing revision-view route.
Linked from the /lignes toolbar, shown only when the current user has
the permission.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 11:25:00 +02:00
bachirandClaude Sonnet 5 10f6271fec Add spreadsheet-style home page, migrate full 2026 ledger
- New /lignes route (set as site front page): full line-by-line table of
  every ligne comptable, Vue app with filters (compte/client/type/année)
  and month/year grouping, columns matching the original spreadsheet
  (one per compte). Rows with répartition ≠ montant HT are visibly
  flagged (red row + écart column), not hidden or auto-corrected.
- /dashboard now only holds the aggregate solde-par-compte/par-client view
- Migrated all 199 real 2026 transaction lines + 9 opening balances (from
  REPORT CLOTURE 2025) via a drush import script, preserving raw source
  data (known répartition mismatches included) -- validated with a new
  state-flag bypass of the presave check, used only for historical import
- Added "Autre" as an allowed field_type_ligne value for edge-case rows
- Client taxonomy grew from 15 seeded terms to the full unified list

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 22:52:00 +02:00
bachirandClaude Sonnet 5 ca800be21a Initial Drupal site: content model + validation + Vue dashboard
- Content type "Ligne comptable" with Paragraphs "Répartition" (Compte + Montant)
- Taxonomies: Compte (9 comptes) and Client (unified client list)
- hook_node_presave + form validate: sum(répartition) must equal montant HT
- /dashboard route (progressive decoupling): Vue 3 app fetching JSON:API,
  computing solde par compte / par client client-side
- "Ajouter une ligne" opens the real Drupal node form in an AJAX modal
- Gin as default + admin theme
- 9 opening-balance lines seeded from suivi_compta_SASFigli2026_v2.ods

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 22:32:54 +02:00