fetchClientNames() requested page[limit]=200 but never followed
links.next, and JSON:API silently clamps to a 50-item hard cap -- with
106 client terms, everything past the 50th alphabetically (e.g. "LE
CAMPUS") was dropped. Now loops through every page like fetchLignes()
already does.
The core Navigation module's admin sidebar (#admin-toolbar) was showing
on our custom /lignes, /dashboard, /lignes/historique and lier-entree
pages. Added a route-scoped library (attached only for those route
names in hook_page_attachments(), not folded into the always-on
admin_chrome attachment) so real Drupal admin pages keep the sidebar.
Replacing the main table's rows with the filtered group made the browser
clamp scrollTop to 0 the moment the drill-down shrank the visible content,
so closing it never returned to where the user had been scrolled. A modal
overlay leaves the main table (and its scroll position) untouched entirely.
versementStatus() returned null whenever a linked versement's own
compte(s) had no residual against any linked entrée -- meaning a
versement that was linked but fully reconciled got no badge at all,
silently losing the only way to open its drill-down (the badge is
also the click target). Only "not linked" and "has a residual" ever
rendered one.
Now always returns a status for any versement, adding a fourth kind
("ok": linked, no residual on any checked entrée) alongside
non-liee/reste/sur-verse. Renders as a plain "Lié" badge in the
default green (no is-anomalie/is-reste modifier), matching the
convention the entrée side already uses for a fully-settled "N
sorties liées" badge, and stays clickable since item.entreeLieeIds
is still non-empty.
Verified: several previously badge-less linked versements (e.g.
"facture-SC-Bachir-260329B-FIGLI", "F58_260506_FIGLI") now show a
green "Lié" badge, and clicking one opens the same 3-row drill-down
(entrée + both its linked sorties) as before.
Three related changes to the /lignes table:
1. The "Client" filter is now a text input with a <datalist> instead
of a <select> -- 50+ clients made the dropdown unwieldy. v-model.lazy
(not the default per-keystroke binding) since a change here triggers
ensureScrollable() and a hash rewrite, which shouldn't fire on every
character typed.
2. Clicking a "versement freelance" row's own status badge (Non liée /
Reste à verser / Sur-versé) now drills down the same way an entrée's
"N sorties liées" badge already did, instead of only being clickable
from the entrée side.
3. field_entree_liee is now multi-value (cardinality unlimited) --
sometimes one payment covers several client invoices at once.
LinkEntreeForm uses #tags => TRUE (a single comma-separated
autocomplete field, Drupal's field-API-native multi-value shape on
submit, no manual tag parsing needed). This is the deeper change and
touches most of the reconciliation logic in home.js:
- buildRows() reads field_entree_liee as an array
(entreeLieeIds/entreeLieeLabels) -- JSON:API always returns a list
for a multi-cardinality relationship now, even with 0 or 1 items.
- sortiesByEntree indexes a sortie under every entrée it links to.
- reconciliationByEntree splits a multi-linked sortie's répartition
equally across its linked entrées -- there's no per-link amount to
divide by, so equal split is the least-wrong assumption available
rather than counting the sortie's full amount against every linked
entrée (which would double-count the same money).
- versementStatus() sums residuals across all of a versement's linked
entrées for its own compte(s), skipping any not in the currently
loaded window (same accepted trade-off reconciliationByEntree
already had).
- The drill-down (filterEntreeId) is now filterEntreeGroup, a
transitive closure over shared entrée<->sortie links -- clicking
one entrée (or, per #2, one versement) surfaces every other entrée
it's connected to through a shared sortie, and every sortie linked
to any of them, not just the originally-clicked one's direct links.
Verified end-to-end: linked a real unlinked versement to two entrées
for the same client via the actual form submission (no manual DB
edit), confirmed both persisted, confirmed the link button's tooltip
lists both, and confirmed clicking either the versement's or an
entrée's badge produces the same 3-row connected group with correct
drill-down footer totals. Reverted the test link afterward.
#compte=Maud&client=EPAU&type=versement&annee=2023&ecarts=1&aller=2024
-- reload the page or send the link and the same filtered view (or
year jump) comes back.
readHashState()/buildHashString() handle the encoding; syncHash()
writes back via history.replaceState() (not pushState -- tweaking a
dropdown shouldn't spam the back button with history entries), called
from the existing filter watchers plus jumpToYear(). "aller" isn't an
ongoing filter (jumpYearValue always resets to '' right after firing)
so its target year is tracked separately (lastJumpYear) purely for the
hash, and omitted whenever "annee" is also present -- the two describe
overlapping year state and jumpToYear() already clears an active
"Année" filter when it runs, so keeping both would just be redundant.
mounted() applies compte/client/type/écarts unconditionally (they only
narrow filteredRows, safe regardless of load path), then branches:
annee in the hash sets filterYear (triggering enterYearMode() same as
manual use), aller calls jumpToYear() directly, otherwise the default
load()+scroll-to-bottom path runs as before -- each path already
manages its own loading state and scroll position, so only one runs.
Verified all three round-trip through reload: compte+type together
(ensureScrollable extends to find matches, as before), aller=2023
(first fetch targets exactly 2023-01-01..2024-06-30), and annee=2022
(every loaded row's data-year is 2022, filter dropdown reflects it).
Default no-hash load is unaffected.
versementStatus() was reusing reconciliationByEntree()'s aggregate
resteAVerser/surVerse, which sums residuals across every compte the
entrée touches -- including comptes tied to *other* sorties linked to
the same entrée. A versement paid entirely through Maud could show
"reste à verser" driven by an unrelated Sandrine/Chloé shortfall on
the same entrée, or vice versa mask its own compte's sur-versement
behind an unrelated compte's surplus.
reconciliationByEntree() now also keeps a per-compte residual map
(parCompteResidual), and versementStatus() sums only the residuals for
the compte(s) this specific versement's own répartition touches.
Verified against a real case: entrée EPAU F2549-50-51 (répartition
across 8 comptes) with one linked "Versement Maud" of -10 000€ against
an entrée-side Maud share of 5 632,86€. The entrée's own badge still
correctly shows the aggregate ("reste 24 325,54 € · sur-versé
4 367,14 €"), but the versement row itself now shows "Sur-versé :
4 367,14 €" -- its actual Maud-only residual -- instead of the
previous "Reste à verser", which was purely an artifact of the other
7 comptes' unrelated shortfalls.
Clicking an entrée's "N sorties liées" badge drills down to just that
entrée + its linked sorties (filterEntreeId) -- but the footer kept
showing the current year's totals throughout, which answers a
different question than the one this view exists for ("does this
entrée balance against what was paid out").
drilldownTotals() computes the same shape locally (montant_ht/ttc,
écart, par_compte) from the already-loaded drill-down rows -- no
server round-trip needed, unlike the per-year figures. footerTotals()
picks between it and currentYearTotals depending on whether
filterEntreeId is set, so the footer swaps automatically and reverts
the moment the drill-down is cleared.
Verified: montant_ht and every compte column match the drill-down's
two rows summed by hand exactly (to the cent), and closing the
drill-down correctly restores the normal per-year footer.
For "versement freelance" rows specifically: an amber outline plus a
badge when the versement either isn't linked to any entrée client at
all ("Non liée"), or is linked but reconciliationByEntree still shows
a residual on that entrée ("Reste à verser" / "Sur-versé", with the
amount in the tooltip). The residual belongs to the entrée as a whole,
not to any one sortie -- when several versements share an entrée, each
shows the same aggregate figure, since there's no way to attribute the
shortfall to one specific payment. No highlight when the linked entrée
isn't in the currently loaded window (can't tell either way -- same
accepted trade-off as reconciliationByEntree itself).
Verified: filtering to "Versement freelance" shows 21 of 25 loaded
rows flagged (17 unlinked, 4 with a reste-à-verser residual), the
remaining 4 fully-reconciled rows correctly unflagged, and the amber
outline resolves correctly in dark mode.
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.
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.
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.
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>
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>
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>
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>
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>
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>
- 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>