Commit Graph
75 Commits
Author SHA1 Message Date
bachirandClaude Sonnet 5 c7c143e038 Revert: force jj/mm/aaaa on the ligne_comptable date field
Back to the native <input type="date"> widget, per request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 18:07:52 +02:00
bachirandClaude Sonnet 5 463a7f343c Force jj/mm/aaaa on the ligne_comptable date field, regardless of browser locale
The native <input type="date"> widget's displayed digit order follows
the browser's own locale, not Drupal's site language -- an English-
locale browser was showing 07/26/2024. Switches the date sub-element to
'text' with an explicit PHP date format so Drupal parses/displays it
consistently for everyone.

Verified end-to-end (not just cosmetically): submitted 05/03/2024 through
the real add form and confirmed it stored as 2024-03-05 (5 March), not
2024-05-03 -- genuinely parsed as day/month, not silently reinterpreted
as month/day.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 18:03:18 +02:00
bachirandClaude Sonnet 5 775ee0955f Display /lignes dates as aa/mm/jj instead of jj/mm/aa
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 17:56:57 +02:00
bachirandClaude Sonnet 5 78661628fc Make the "Reste à verser" badge dark green instead of orange
Distinguishable from the default badge green (used for "Lié") while
staying green rather than orange -- it's an amount owed, not a warning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 16:51:22 +02:00
bachirandClaude Sonnet 5 9ce5d87ace Surface signalement (flag) tags on the per-compte dashboard
Fetches field_flag alongside the existing entrée/versement data (same
JSON:API include list, same buildRows() shape as home.js's flags/
hasFlag). Two changes:

- Rows already shown in the reste-à-verser/sur-versé/non-liés tables
  get the same amber left-edge accent + tag badges as /lignes when
  they're also flagged.
- New "Lignes signalées" section lists every flagged entrée/versement
  for the selected compte, including ones that don't appear in any of
  the other tables -- a fully-settled line can still carry a flag for
  an unrelated reason (e.g. "client injoignable"), which none of the
  reconciliation-based tables would otherwise surface.

Verified live against an isolated test node plus two real flagged lines
already present in production data (client had already started using
the /lignes signalement feature) -- both the section and the inline
badges render correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 16:43:44 +02:00
bachirandClaude Sonnet 5 3f8767b4ef Narrow edit columns for wider amounts, modal-based signalement, fix scroll storm
Three related /lignes fixes:

1. Column widths: shrink Date/Facture/Libellé/Signalement (the four
   editable text columns) to free up room for the amount columns
   (Montant HT/TTC, the 8 compte columns, Écart), which were cramped.
   Date/Facture stay nowrap (already short: jj/mm/aa, Fxxxxxxx); Libellé/
   Signalement keep wrapping.

2. Signalement editing: replaced the comma-separated inline text input
   with a small modal -- one tag per line, each with its own remove
   button, plus an add field at the bottom. Clearer than parsing/
   retyping a whole comma list to drop one tag. Backend endpoint is
   unchanged (still takes a comma-joined value); only the front-end
   interaction model changed.

3. Scroll storm: a continuous scroll gesture fires many native 'scroll'
   events, and checkEdges() ran on every one of them -- each qualifying
   event queued its own loadOlder()/loadNewer() call (queuing, not
   dropping, was the previous session's fix for a *different* bug), and
   every queued call did a real fetch + scroll compensation regardless
   of whether an earlier one already moved the window away from the
   edge. That pileup is what looked like the same request firing over
   and over and dragged the scroll position around unpredictably.
   Fixed by guarding checkEdges() with the existing loadingOlder/
   loadingNewer flags so it stops queuing once one's already in flight.
   (A first attempt at this suppressed the compensation write's own
   resulting scroll event via a flag cleared on requestAnimationFrame --
   reproduced, live, the exact "stuck forever" failure already fixed
   once this session for the old rAF-based scroll throttle, because rAF
   doesn't reliably fire in this environment. Removed: turns out no
   suppression is needed at all, since that event finds loadingOlder
   already true and the checkEdges() guard blocks it on its own.)
   Also added a re-entrancy guard to pollForChanges(), which had no
   protection against a slow response overlapping with the next
   setInterval tick.

Verified with scripted scroll stress tests (dense bursts of 40-100
events, and repeated attempts to scroll back from the very top): exactly
one fetch per genuine edge crossing, no duplicate requests, scroll
position stays correctly anchored, never snaps back down.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:47:34 +02:00
bachirandClaude Sonnet 5 fa28783bcc Fix table jitter, horizontal scroll, and vertical scroll snapping on /lignes
Root cause: the main table used auto layout, so every loadOlder()/
loadNewer() reflowed every column's width based on whatever was
currently loaded -- visibly shifting the table, spilling past the
viewport into a horizontal scrollbar, and corrupting loadOlder()'s
scroll-position compensation (which assumes the scrollHeight delta
after prepending rows is *only* the new rows' own height -- not true
once existing rows also reflow). That's what made scrolling up feel
like it kept snapping back down.

- table-layout: fixed with explicit per-column widths (percentages
  throughout, not mixed with rem -- mixing meant the rem columns' width
  was added on top of the percentage budget instead of coming out of
  it), plus box-sizing: border-box so padding doesn't inflate columns
  beyond their declared width.
- overflow-x: hidden instead of auto on the scroll container: with both
  x and y auto on the same element, the browser has to guess whether a
  vertical scrollbar will appear before laying out width: 100%, and a
  wrong guess understates available width by a scrollbar's worth --
  exactly enough to tip a tightly-fitting table into needing horizontal
  scroll too.
- Fixed a footer-row column count bug found along the way: it still had
  two actions-col cells and a colspan=5 label from before the link
  button was removed, leaving the label 6 columns short of Signalement
  and misaligning every footer cell after it.

Verified with a 25-round scripted scroll-up stress test spanning 3
loadOlder() triggers: scroll position stays correctly anchored near
where the user was looking, never jumps toward the bottom.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:16:06 +02:00
bachirandClaude Sonnet 5 eda79e75a3 Simplify /lignes row indicators: drop redundant link button and écart outline
- Remove the left-side link button (actions-col) -- the link status
  badge already does the same thing (view linked entrées, or open the
  link form when unlinked), so the button was pure duplication.
- Remove the red row outline on écart lines -- the Écart column itself
  (bold red text) already flags it, no need for a second row-level cue.
- Hide the link status badge specifically on "Hébergement" lines (both
  the main table and the drill-down modal) -- reconciliation math is
  untouched (hébergement sorties still count toward other rows' totals),
  this only suppresses the badge on hébergement's own row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:00:29 +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
bachirandClaude Sonnet 5 3404ee14c3 Fix sliding window silently stalling under multi-filter URL hashes
Restoring several filters at once from the URL hash (client + type)
fired several concurrent loadOlder()/loadNewer()/ensureScrollable()
chains. They raced on the loadingWindow guard, which silently dropped
a call arriving mid-flight instead of queuing it -- indistinguishable
from "nothing more to load", so the sliding window gave up expanding
after one round even when the table was still far from scrollable,
with no scroll events left to ever retry it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:06:02 +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 0c893b1b24 Show the exact solde amount directly in the versement badge
The reste-à-verser/sur-versé badge only showed the word, with the exact
figure available in the hover title -- had to open the drill-down modal
just to see how much. linkStatus() now exposes the computed amount
(montant) alongside kind/detail, and linkStatusLabel() takes the whole
status object to format it inline: "Reste à verser : 440,00 €",
"Sur-versé : 710,00 €", "Lié : 0,00 €" for a fully settled one.

Verified live: all three cases render with the exact figure in both the
main table and the drill-down modal.
2026-09-06 12:32:41 +02:00
bachir fc89ae6232 Resolve versement reste-à-verser/sur-versé from the server proactively
The badge on a versement freelance row previously only got the correct
status once someone clicked it -- before that it could show "inconnu"
(or, worse, silently default to "ok") whenever its linked entrée wasn't
in the currently loaded sliding window, which is common: the entrée
that got paid can be dated years before or after the versement itself.

Also fixed a latent bug in the click path itself: toggleEntreeFilter()
passed the *entrée's* id to loadEntreeGroup(), which needs an
already-loaded row to find a starting nid for the server-side query --
if that entrée wasn't loaded (the exact case this is all about), the
lookup silently found nothing and did nothing. Split the fetch+merge
logic into fetchGroupFromNid() and let loadEntreeGroup() accept a
fallbackNid (the versement's own, always loaded) to start the
traversal from when the entrée itself isn't available locally --
LedgerStatsController::groupeEntree() finds the same connected
component either way.

New ensureLinkedReconciliationResolved(), triggered by a `rows` watcher
after every load/loadOlder/loadNewer, calls the same server-backed
resolution for every visible linkable row instead of waiting for a
click, deduplicated via resolvedLinkGroups so rows sharing an entrée
don't each trigger their own fetch.

Verified live: the LA MINE versement (own date 2022, linked entrées
dated 2023 and 2025) now shows "⚠ Sur-versé" immediately on page load
with zero clicks, where before it required manually opening the badge
first. Drill-down modal and the "Non liée" -> link-form click path both
still work; no console errors on repeated fresh loads.
2026-09-06 12:21:53 +02:00
bachir 18cb4cc3c8 Auto-focus click-to-edit fields instead of requiring a second click
Type/Client/Facture/Libellé all swap a span for an <input>/<select> via
v-if -- Vue doesn't focus a newly created element on its own, so the
first click only revealed the field and a second click was needed to
actually type into it. Added a v-focus directive (mounted() calls
el.focus() + el.select(), firing exactly once per v-if true-flip) to
all four.

Verified live: a single click on each of the four editable cells now
moves document.activeElement into the field immediately.
2026-09-06 11:14:02 +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 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