Two problems: (1) the node form's modal AJAX submit handler
(figli_compta_ledger_node_form_ajax_submit) closed the dialog on
success without ever surfacing whatever messenger() had queued during
save() -- e.g. NodeForm's own "Ligne comptable X has been updated."
Since the /lignes table only ever refreshes via AJAX afterwards, never
a full page load, that message was silently lost instead of just
delayed. Now forwarded via MessageCommand before closing the modal.
(2) Gin's default message styling is a full-size flash banner meant
for a single admin form, in-flow (pushing the page down) and far too
large for frequent background feedback on this dashboard.
admin-chrome.css repositions [data-drupal-messages] /
[data-drupal-messages-fallback] to fixed top-right (out of document
flow, so nothing shifts) and shrinks text/padding/dismiss-button size
drastically while keeping them legible. admin-chrome.js auto-dismisses
non-error messages after 2s via a MutationObserver (Drupal.Message.add()
just appends a DOM node, it never re-fires attachBehaviors(), so
behaviors/once() would miss anything added after the initial page
load); error messages are left alone, requiring the existing manual
click on Gin's own dismiss button. Both attached globally via the
existing admin_chrome library (already loaded on every page for the
phantom-top-bar fix), consistent with that precedent.
Verified: a real full-form save on a clean node returns exactly
{message (status, "has been updated"), closeDialog} in that order: a
répartition-inconsistent node still correctly blocks the full form
(unaffected, skip_validation is never used here); a synthetic
status/warning/error trio confirms fixed positioning, no layout
shift, ~40% smaller text, and status/warning auto-hiding while error
persists until manually dismissed.
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.
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.
v-if="loading" swaps out <div ref="tableWrap"> for a brand new DOM
node every time loading toggles true -> false. mounted() only attaches
the scroll listener once, to whichever wrap existed at mount time --
enterYearMode(), exitYearMode(), and jumpToYear() all trigger that
swap, silently orphaning the listener on the old (now-detached) node.
After any of those three, scrolling stopped calling checkEdges()/
detectCurrentYear() at all, freezing the footer's year totals.
ensureScrollListener() re-attaches (idempotently, via a dataset flag)
after every such transition. Verified: the wrap element does change
identity across jumpToYear(), and the new element picks up the
listener and fetches totals for the newly-visible year correctly.
Distinct from the existing "Année" filter (which restricts the view
to just that year): this re-centers the sliding window on 1 January
of the chosen year and scrolls there, while staying in the continuous
view -- scrolling still extends the window normally in both
directions, so overlaps between years stay visible, matching the
earlier stated preference for a continuous view over a filtered one.
Resets to the placeholder immediately after firing since it's a
one-shot action, not a sticky filter. Guards against racing the
"Année" watcher's own exitYearMode() when a year filter is already
active when the jump is triggered.
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.
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.
Two compounding issues in the sliding-window edge loading:
- loadOlder()/loadNewer() kept pushing windowStart/windowEnd outward
even when a fetch came back empty (e.g. extending into future dates
with no data yet). Since nothing about the table's height or scroll
position changes when 0 rows come back, checkEdges() stayed
satisfied and the very next scroll/layout tick fired the same load
again, drifting the window further out forever. Now bounded by
MIN_LOADABLE_DATE/MAX_LOADABLE_DATE.
- loadOlder() and loadNewer() had independent in-flight guards and
could run concurrently, racing on the same rows/windowStart/windowEnd
state -- a scroll-position compensation write inside one (from
trimming the far end) fires a real scroll event that can kick off
the other direction mid-flight. Now serialized behind a shared
loadingWindow lock.
The generic totals-row rule (tr.figli-totals-row td) has higher
specificity than the lone td.figli-solde-crediteur/-debiteur rules, so
its neutral color always won in the footer even though soldeClass()
was already applying the right class there.
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.
load() unconditionally set loading = true, which unmounts the whole
v-else table (the "Chargement…" paragraph takes its place) every time
-- including the background refresh after saving a line, which is
exactly the scroll-resetting, full-table-disappears flicker Vue's
keyed diffing is supposed to prevent. The post-edit refresh
(dialog:afterclose) now calls load(false): rows get reassigned in
place, and Vue patches only what changed.
Verified with a real click (not synthetic JS events, which don't
reliably trigger Drupal's mousedown-bound AJAX submit in headless
testing): same .figli-table-wrap DOM node before/after, "Chargement…"
never appeared.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Single anchor per row -- Montant TTC and the 8 compte columns stay
neutral, so the table doesn't turn into a red/green garland. No 0.5€
threshold like soldeClass (used for aggregate totals): individual
lines are often small (a -1.07€ OVH renewal shouldn't read as
"neutral"), any nonzero sign gets colored.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
tfoot had position: sticky but no explicit z-index (auto/0), while the
sticky month/year group-row headers use z-index: 1 -- so a group row
scrolling past the bottom painted on top of the totals footer instead
of behind it. z-index: 3 (above thead's 2 and the group-row's 1).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Link and edit icons get one dedicated column each instead of sharing
one text-align: center cell -- with a shared cell, a row with only the
edit icon (non-linkable types) centered differently than a row with
both icons, so pencils never lined up vertically across rows.
Separate columns line up by construction. Verified: identical left
offset (141px) for the edit icon across 15 consecutive rows.
Also: tighter cell padding (was using the table's default 0.6rem
horizontal padding, way more than an icon needs) and no column-hover
crosshair on these technical columns -- nothing meaningful to compare
across rows there.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
display: flex on td.actions-col was fighting the table's own
column-width calculation -- a ~55px gap opened up between the actions
column and Date, and the buttons rendered outside their cell's actual
boundary. Buttons go back to sitting inline (already inline-flex
individually) inside a plain table-cell, spaced with a margin on the
first one instead of a flex gap on the container.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Dates render as jj/mm/aa instead of the API's ISO yyyy-mm-dd -- saves
width in a table already packed with 8 compte columns. Actions column
(edit pencil, link icon) moves from the right end to the first
column. Column-hover crosshair logic is unaffected: it walks colSpan
positions dynamically rather than assuming a fixed index.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
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>
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>
These had been changed interactively in the admin UI (Gin layout
density -> small, Navigation logo -> hidden) but the exported config
in config/sync had drifted out of sync with the active config -- this
just catches it up.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Core Navigation's top bar only shows itself when its tools/context/
actions regions are non-empty (`:has(:not(:empty))`). With no blocks
placed in those regions, they still contain whitespace text nodes from
Twig's loop scaffolding, which defeats that check -- the bar rendered
anyway, empty, and pushed page content down to make room for it.
Attached globally via hook_page_attachments() rather than scoped to
/lignes or /dashboard, since the Navigation chrome renders on every
authenticated page, not just our custom routes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Leftover Standard-recipe scaffolding: "administrator" duplicated root's
is_admin bypass, "content_editor" had generic permissions unrelated to
this site's content model (a "tags" vocabulary that doesn't exist here,
nothing for ligne_comptable). Neither was assigned to any user.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
root (renamed from the default admin/admin account) keeps full,
unrestricted access outside any role system. These three cover the
actual associates:
- user: read-only, 'access content' only -- can browse /lignes and
/dashboard, nothing else.
- editeur: 'access content' + create/edit any/delete any
ligne_comptable content -- can enter and correct accounting lines,
no site configuration.
- admin: identical permission set to editeur for now (per instruction,
actual config permissions to be scoped later).
Left the pre-existing "administrator" (is_admin bypass -- too broad)
and "content_editor" (generic Standard-recipe scaffolding, permissions
unrelated to ligne_comptable) roles untouched but unused; worth
pruning later if nothing ends up using them.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
All the ledger data is confidential (real client names, amounts,
associate balances) -- revoke 'access content' from the anonymous
role so nodes, taxonomy terms, and JSON:API all deny anonymous reads
(verified: JSON:API returns an empty data[] + "omitted" notice instead
of the records, /lignes and /dashboard 403).
Set the site's 403 page to /user/login: anonymous visitors hitting "/"
(the front page is /lignes) land on the login form instead of an
access-denied page; authenticated users still land on /lignes as
before since they still hold 'access content' via the authenticated
role.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Pencil icon opens the existing node edit form in the same AJAX modal
as "+ Ajouter une ligne" -- no new form logic, reuses the form_alter
validation/close-on-save already in place for the add form.
Column highlight pairs with the existing row hover (from Gin's global
table CSS) to form a crosshair. Column position is computed logically
(accounting for colspan) rather than via DOM cellIndex, since the
totals row's label cell spans 4 columns and would otherwise misalign
every column after it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Committed in git since the initial commit -- contains the DB credentials
and the Drupal hash_salt in clear text, already pushed to gitea and prod.
Keeping the file locally (still needed for the site to run) but no longer
tracked, so future secret rotations don't get recommitted. The already-
exposed history is left untouched for now.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
27 lines (18 previously "charge", 9 "versement") that touch the EXT.WEB
compte in their répartition are now typed "hebergement" instead -- makes
Bachir's separate hosting activity filterable/visible as its own category
rather than blended into general charges/versements. Amounts/répartition
untouched, only the classification changed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- tfoot row: sum per compte (créditeur/débiteur colored) for the
currently filtered rows, plus HT/TTC/écart totals
- Fixed a real bug: fetchAllLignes() paginated without a unique sort key
(field_date_ligne alone, many ties), which let Drupal's JSON:API return
the same row on two pages -- silently inflating totals (Bachir showed
-3115,38€ instead of -3013,56€). Now sorts by
field_date_ligne,drupal_internal__nid (home) / drupal_internal__nid
(dashboard), plus a defensive client-side de-dup by node id either way.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Not used going forward. Deleted the term + its 2 répartition paragraphs
(the standalone opening line was removed entirely since it had nothing
left; the EPAU F_2617 line now shows a real 5214,01€ écart instead of
silently absorbing that amount under a compte nobody uses).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Gin dark mode (html.gin--dark-mode) is on by default and the previous CSS
relied on Gin custom properties that didn't resolve here, causing
black-on-black/white-on-white text. Now uses self-contained explicit
colors with a dark-mode override block. Error rows get a thin red border
instead of a background fill, as requested.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- 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>
- 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>