104 Commits
Author SHA1 Message Date
bachir 179a994f69 Fix empty Facture/Libellé cells having nothing to click
Facture and libellé rendered their span with no fallback text when
empty, unlike Client's existing "|| '—'" -- an empty span has zero
width/height, so there was nothing there to click at all. Added the
same "—" placeholder to both.

Also switched .figli-editable-cell to display: block with a min-height,
so the clickable area fills the whole cell's content box instead of
just hugging the text -- a lone "—" character was still a tiny target
even with a placeholder.
2026-09-06 10:52:23 +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 b6540e56c7 Add field_numero_facture (N° Facture), a Facture column, and backfill existing content
New plain string field on ligne_comptable, positioned after Client in
both form and view displays. Added as a "Facture" column in both the
main table and the entrée/sortie drill-down modal, right after Type.

Backfilled via figli_compta_ledger_update_8002() by extracting an
invoice number from field_notes (or title, same fallback the front-end
libellé already uses) wherever the pattern is unambiguous: literal F,
optional _/-, 2-8 digits, optional "-digits" continuations for a
compound reference (e.g. "F2549-50-51"), optional 0-3 trailing
uppercase letters (e.g. "F250427A"), with a hard boundary right after --
not immediately followed by more letters/digits/underscore. That last
part is what skips "F58_260506_FIGLI" or "F_2601_FIGLI_EPAU": no way to
tell whether the trailing "_xxx" belongs to the reference or is an
unrelated client/description code glued on, so those are left blank
rather than guessed, per explicit instruction to skip when unsure.

Verified the pattern against every existing ligne_comptable's
notes/title before writing the migration: 319 confident matches with no
false positives found on manual review of the full list, 1236 left
blank. Applied via drush updb, config exported.
2026-09-05 22:37:34 +02:00
bachir 852eda2056 Show the répartition validation error message instead of failing silently
Editing a ligne_comptable with an inconsistent répartition correctly
blocked the save (setErrorByName() on field_repartition), but the
message text had nowhere to render: it normally goes through the page's
status-messages region, which isn't part of this standalone AJAX
ReplaceCommand fragment. The form re-render did mark every répartition
row with a subtle red outline, but with no visible explanation the modal
just looked stuck.

figli_compta_ledger_node_form_ajax_submit() now also emits a
MessageCommand for each form error, mirroring the pattern already used
for the success path's messenger() messages. Verified live: breaking a
répartition sum and clicking Save now shows a proper error toast with
the exact mismatch amount, the modal correctly stays open, and the node
is confirmed unchanged in the database.
2026-09-05 22:08:42 +02:00
bachir bb01c2dd90 Turn Compte/Type filters into hover dropdowns, add a clear button to each filter
A native <select multiple> can't collapse to a single-line trigger and
open as an overlay on hover -- it's either an always-open multi-row
list box or a single-value dropdown, not both. Replaced with a custom
dropdown: a trigger showing the current selection ("Tous" / "Maud,
Ouidade"), and a checkbox list shown on hover via a plain CSS :hover
rule (no open/close state needed). Checkboxes instead of ctrl/shift+click,
but same result -- several comptes/types at once -- with a more
discoverable interaction (no keyboard modifier to know about).

Also added a "✕ Effacer ce filtre" button next to Compte/Client/Type/
Année, shown only once that filter is active. Directly addresses the
Client filter specifically being painful to clear (select the text,
delete, press enter) -- one click now.
2026-09-05 21:55:50 +02:00
bachir 319b22207d Allow selecting several comptes/types at once in the filters
Compte and Type were single-value <select> elements. Converted both to
<select multiple> -- Vue binds those to an array natively, so
ctrl/cmd+click and shift+click just work with no custom JS. Matching
semantics are OR within a filter (any of the selected comptes/types) and
AND across filters, same as the existing client/year/écarts filters.

The URL hash (compte=Maud,Bachir&type=versement,achat) now carries
comma-separated lists instead of a single value, so the shareable/
reloadable filtered view still works with multiple selections.
2026-09-05 21:48:59 +02:00
bachir 0479f3fbbb Fix scroll-triggered loading permanently stopping after one round
onScroll() throttled via requestAnimationFrame with a guard
(_scrollRaf) reset from *inside* the rAF callback -- if that callback
ever failed to fire (reproduced via a backgrounded/non-visible tab,
where browsers routinely throttle or suspend rAF), the guard stayed
true forever, silently dropping every future scroll event. Matches
exactly what was reported: the window extends once when scrolling
toward the past, then stops responding to scrolling at all.

detectCurrentYear()/checkEdges() are cheap (DOM reads + early-return
guards, do no real work themselves), so there's nothing worth
throttling here -- removed the rAF entirely and call both directly on
every scroll event. Verified live: repeated scroll-to-top no longer
gets stuck after the first extension, keeps loading older months
across many rounds.
2026-09-05 21:39:47 +02:00
bachir 0c9f8ca41f Show the link-status badge on all linkable types, not just versement
Hébergement (and achat/sous-traitant) were already linkable to an
entrée client -- LINKABLE_TYPES already included them, so the "Lier"
button and the reconciliation math worked fine -- but versementStatus()
hardcoded item.type !== 'versement' and returned null for every other
type, so those rows never got the Non liée/Lié/Reste à verser/Sur-versé
badge at all, and had no way to open the link form except the small
actions-column icon.

Renamed versementStatus()/versementStatusLabel()/versementStatusClasses()
to linkStatus()/linkStatusLabel()/linkStatusClasses() and generalized
the type check to LINKABLE_TYPES.includes(item.type). Verified live: a
hébergement row linked to an entrée now shows the same badge, status
math, and drill-down modal as a versement.
2026-09-05 21:17:18 +02:00
bachir 8f65a9097a Make the "Non liée" versement badge clickable to open the link form
It was the only versement status that did nothing on click -- every
other kind opens the drill-down modal. Now it opens the same "Lier à une
entrée client" form as the actions-column link button, since that's
exactly what a non-liée versement needs next.
2026-09-05 19:51:43 +02:00
bachir 2a6d36c603 Hide empty compte columns in the drill-down modal, rename its title
The modal's group is usually 2-4 rows touching only 1-2 comptes -- unlike
the main table (always all 8, so columns line up across every row/year),
showing all 8 there was mostly empty columns. New modalComptes computed
filters allComptes down to whichever actually carry a value somewhere in
filterEntreeGroup.

Also renamed "Entrée + sorties liées" to "Entrées et sorties liées"
(title and footer label) to match multi-entrée groups.
2026-09-05 19:32:38 +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 619a2ab70c Stop hiding the top bar globally -- it hid the Save button on entity edit forms
.top-bar was unconditionally hidden on every page (admin_chrome library)
on the assumption it always renders empty, which held on our own pages
but not on entity edit forms, where Gin puts the page's local actions --
Save included -- inside .top-bar. Folded it into the same route-scoped
library as the admin sidebar (renamed hide_admin_sidebar ->
hide_admin_chrome) so both are hidden only on our front-end routes and
real admin pages keep a working Save button.
2026-09-05 17:01:39 +02:00
bachir 73818779c1 Fix client autocomplete pagination + hide admin sidebar on front-end pages
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.
2026-09-05 13:27:33 +02:00
bachir 82b03ac5e2 Fix horizontal scroll in the entrée/versement drill-down modal
900px was way too narrow for 16 columns forced to nowrap. Widened the
modal to 96vw, let cells wrap instead of forcing nowrap, and gave
columns proportional widths (table-layout: fixed otherwise spread all
16 evenly, squeezing the libellé text into the same width as an empty
compte column).
2026-09-05 13:20:13 +02:00
bachir 9ec0aa4699 Open the entrée/versement drill-down in a modal instead of in place
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.
2026-09-05 13:16:44 +02:00
bachir 0f33d57ec0 Show a badge on linked-and-settled versements too
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.
2026-09-05 12:53:30 +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 0170ec4475 Make filters and "Aller à" reloadable/shareable via the URL hash
#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.
2026-09-05 11:17:33 +02:00
bachir 9a70205c83 Scope versement reste-à-verser to its own compte(s), not the entrée total
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.
2026-09-05 11:09:37 +02:00
bachir 6bdf12b8fe Show the drill-down's own totals in the footer, not the year's
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.
2026-09-05 11:02:01 +02:00
bachir 5a311b8439 Drop the versement-warning row outline, keep only the badge
Too much visual noise -- the badge alone already says everything
needed. Removes the now-unused --figli-warning custom property along
with the outline rule.
2026-09-05 10:49:56 +02:00
bachir 1ab38bc2a7 Highlight versements not (fully) backed by their entrée
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.
2026-09-05 10:40:01 +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 2947a542da Surface Drupal messages as small top-right toasts, auto-dismissed
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.
2026-09-04 22:17:52 +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 a45d55cb81 Fix footer freezing after Année filter / Aller à / retour à Toutes
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.
2026-09-04 21:39:19 +02:00
bachir 53c4e58e72 Add "Aller à" year-jump shortcut next to the filters
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.
2026-09-04 21:33:59 +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 831c0a0e62 Fix runaway scroll-loading loop on the grand livre
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.
2026-09-04 21:12:20 +02:00
bachir 9e489b48e6 Fix footer solde colors not showing red/green
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.
2026-09-04 20:58:44 +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 059d31f63d Stop the table flickering away on every edit save
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>
2026-09-04 13:10:55 +02:00
bachirandClaude Sonnet 5 4636fc3522 Color and bold Montant HT to distinguish entrées from sorties
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>
2026-09-04 12:53:55 +02:00
bachirandClaude Sonnet 5 8eb92c613d Fix totals footer painting behind sticky group-row headers on scroll
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>
2026-09-04 12:50:23 +02:00
bachirandClaude Sonnet 5 13461d0cb1 Split action icons into their own columns, tighten spacing, no crosshair
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>
2026-09-04 12:47:44 +02:00
bachirandClaude Sonnet 5 5f4ec4dbee Fix actions column layout: display:flex on a <td> broke the table
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>
2026-09-04 12:44:24 +02:00
bachirandClaude Sonnet 5 f3ce872b02 Compact date format, move action buttons to the left
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>
2026-09-04 12:37:06 +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 1f357e5f1c Hide the phantom empty top bar, keep only the Gin sidebar
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>
2026-09-04 11:09:02 +02:00
bachirandClaude Sonnet 5 8c63c19619 Add per-row edit button and paired column highlight to /lignes
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>
2026-09-04 10:40:21 +02:00
bachirandClaude Sonnet 5 f2a8f469b3 Add "Hébergement" type, reclassify EXT.WEB sorties into it
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>
2026-09-03 23:07:12 +02:00
bachirandClaude Sonnet 5 2ebfa3b413 Add solde totals footer row + fix JSON:API pagination duplicate bug
- 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>
2026-09-03 23:04:33 +02:00