Compare commits

...
58 Commits
Author SHA1 Message Date
bachir 9234050a68 Ajoute un filtre par tag de signalement sur /lignes et /dashboard/compte
Même modèle multiselect/OR que les filtres Compte et Type déjà en place,
en complément (pas en remplacement) du simple on/off "Signalées
uniquement" du grand livre. Sur /dashboard/compte, la liste d'options se
limite aux tags réellement présents pour le compte sélectionné, et se
réinitialise au changement de compte.
2026-09-06 21:59:32 +02:00
bachirandClaude Sonnet 5 11250bea77 Add solde footers + per-année small multiples to /dashboard/compte
1. "Entrées sur-versées" and "Versements sans entrée liée" tables get a
   solde footer, same convention as "Reste à verser" above them --
   reuses the totalSurVerse/totalNonLies computeds already backing the
   summary cards, no new computation.

2. "Répartition de l'activité par type" and "Top clients" keep their
   all-time chart, now followed by a small-multiples grid of the same
   chart per année. The type breakdown reuses
   total_par_type_par_compte_par_annee, a new field on
   DashboardStatsController::stats() built from the same répartition-
   level rows already fetched for total_par_type_par_compte (no extra
   query, just one more level of grouping in the PHP aggregation). Top
   clients per année is computed client-side from the same
   entreesDuCompte already used for the all-time version, capped to top
   5 (not 10) to keep the grid readable.

HBarChart gains a `compact` prop (narrower fixed columns, smaller text)
for use inside the small-multiples cards -- the all-time charts above
keep the full-width layout unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 20:41:11 +02:00
bachirandClaude Sonnet 5 ec2af0ea7e Add "Répartition de l'activité par type" chart to /dashboard/compte
Extends DashboardStatsController::stats()'s répartition-level SQL query
to also group by type (not just année/compte), producing a new
total_par_type_par_compte breakdown alongside the existing global one.
Unlike the reconciliation tables on this page (deliberately entrée/
versement only), this chart covers every type touching the selected
compte's répartition, matching what the general /dashboard already
shows for the whole ledger.

Fixed a latent bug the query change would otherwise have introduced:
solde_par_compte_par_annee[année][compte] used to be a 1:1 assignment
because each (année, compte) pair was unique in the old query -- adding
type to the GROUP BY means several rows can now share that same pair,
so it has to accumulate instead of overwrite (verified the accumulated
totals exactly match a query without the type split, so this preserves
existing behavior for the fields already in use).

HBarChart in dashboard-compte.js gained the same colorFor prop
dashboard.js's version already has (existing Top clients usage keeps
its default green, unaffected).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 20:35:10 +02:00
bachirandClaude Sonnet 5 bd251d0a5e Fix diverging year-bars chart: bars were pinned to the top, not the zero line
Root cause: .figli-year-bar was position: relative with an explicit
height (set inline by barStyle()), which opts it out of the flex
container's default stretch alignment -- so it rendered flex-start
(top) aligned first, and the top/bottom: 50% from barStyle() only
*offset* that already-top position instead of anchoring an edge to the
middle. Visibly: green (positive) bars piling up near the top instead
of growing upward from the zero line.

Fixed by wrapping each bar in a .figli-year-bar-slot that stays in the
normal flex flow (so horizontal side-by-side layout for two bars/year
still works) and giving the bar itself position: absolute, anchored
against the slot's full-height box -- that's what makes a 50% top/
bottom offset actually mean "the zero line" instead of "50% further
down/up from wherever flex already put it."

Verified geometrically in-browser: bars now straddle the track's
midpoint and extend outward in the correct direction (positive up,
negative down) on both "Entrées vs versements par année" and
"Évolution du solde".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 18:14:00 +02:00
bachirandClaude Sonnet 5 22f57379e5 Add solde footer to the "Lignes signalées" table on /dashboard/compte
Sum of the Montant column across every flagged line for the selected
compte, styled red when negative -- same convention as the other
reconciliation tables' footers on this page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 18:10:25 +02:00
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
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
31 changed files with 4924 additions and 324 deletions
@@ -6,9 +6,11 @@ dependencies:
- field.field.node.ligne_comptable.field_client
- field.field.node.ligne_comptable.field_date_ligne
- field.field.node.ligne_comptable.field_entree_liee
- field.field.node.ligne_comptable.field_flag
- field.field.node.ligne_comptable.field_montant_ht
- field.field.node.ligne_comptable.field_montant_ttc
- field.field.node.ligne_comptable.field_notes
- field.field.node.ligne_comptable.field_numero_facture
- field.field.node.ligne_comptable.field_repartition
- field.field.node.ligne_comptable.field_type_ligne
- node.type.ligne_comptable
@@ -53,6 +55,16 @@ content:
size: 60
placeholder: ''
third_party_settings: { }
field_flag:
type: entity_reference_autocomplete_tags
weight: 8
region: content
settings:
match_operator: CONTAINS
match_limit: 10
size: 60
placeholder: ''
third_party_settings: { }
field_montant_ht:
type: number
weight: 3
@@ -75,6 +87,14 @@ content:
rows: 5
placeholder: ''
third_party_settings: { }
field_numero_facture:
type: string_textfield
weight: 3
region: content
settings:
size: 60
placeholder: ''
third_party_settings: { }
field_repartition:
type: paragraphs
weight: 5
@@ -6,9 +6,11 @@ dependencies:
- field.field.node.ligne_comptable.field_client
- field.field.node.ligne_comptable.field_date_ligne
- field.field.node.ligne_comptable.field_entree_liee
- field.field.node.ligne_comptable.field_flag
- field.field.node.ligne_comptable.field_montant_ht
- field.field.node.ligne_comptable.field_montant_ttc
- field.field.node.ligne_comptable.field_notes
- field.field.node.ligne_comptable.field_numero_facture
- field.field.node.ligne_comptable.field_repartition
- field.field.node.ligne_comptable.field_type_ligne
- node.type.ligne_comptable
@@ -47,6 +49,14 @@ content:
third_party_settings: { }
weight: 2
region: content
field_flag:
type: entity_reference_label
label: above
settings:
link: true
third_party_settings: { }
weight: 8
region: content
field_montant_ht:
type: number_decimal
label: above
@@ -76,6 +86,15 @@ content:
third_party_settings: { }
weight: 6
region: content
field_numero_facture:
type: string
label: above
settings:
link_to_entity: false
link_rel: canonical
third_party_settings: { }
weight: 3
region: content
field_repartition:
type: entity_reference_revisions_entity_view
label: above
@@ -0,0 +1,25 @@
uuid: c89c4d76-167a-4b60-b5f1-50aa3b349b80
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_flag
- node.type.ligne_comptable
- taxonomy.vocabulary.flag
id: node.ligne_comptable.field_flag
field_name: field_flag
entity_type: node
bundle: ligne_comptable
label: Signalement
description: ''
required: false
translatable: true
default_value: { }
default_value_callback: ''
settings:
handler: 'default:taxonomy_term'
handler_settings:
target_bundles:
flag: flag
auto_create: true
field_type: entity_reference
@@ -0,0 +1,19 @@
uuid: f090cb6f-56d8-44cf-b18c-9f81fd3fd518
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_numero_facture
- node.type.ligne_comptable
id: node.ligne_comptable.field_numero_facture
field_name: field_numero_facture
entity_type: node
bundle: ligne_comptable
label: 'N° Facture'
description: ''
required: false
translatable: true
default_value: { }
default_value_callback: ''
settings: { }
field_type: string
@@ -12,7 +12,7 @@ settings:
target_type: node
module: core
locked: false
cardinality: 1
cardinality: -1
translatable: true
indexes: { }
persist_with_no_fields: false
@@ -0,0 +1,20 @@
uuid: 0921a265-d874-487c-bf0a-09a389322d6b
langcode: en
status: true
dependencies:
module:
- node
- taxonomy
id: node.field_flag
field_name: field_flag
entity_type: node
type: entity_reference
settings:
target_type: taxonomy_term
module: core
locked: false
cardinality: -1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false
@@ -0,0 +1,21 @@
uuid: 6819b88a-a788-471f-bae0-a363bb9a1670
langcode: en
status: true
dependencies:
module:
- node
id: node.field_numero_facture
field_name: field_numero_facture
entity_type: node
type: string
settings:
max_length: 64
case_sensitive: false
is_ascii: false
module: core
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false
@@ -32,6 +32,15 @@ settings:
-
value: hebergement
label: Hébergement
-
value: sous_traitant
label: Sous-traitant
-
value: salaire_stage
label: 'Salaire / stage'
-
value: charges_local_pro
label: 'Charges local pro'
allowed_values_function: ''
module: options
locked: false
+9
View File
@@ -0,0 +1,9 @@
uuid: 01c508f8-ba10-40d7-8d8b-5c2c362bd761
langcode: en
status: true
dependencies: { }
name: Signalement
vid: flag
description: null
weight: 0
new_revision: false
@@ -1,14 +1,111 @@
/* Core Navigation's top bar (`.top-bar`) only shows itself when its
tools/context/actions regions are non-empty -- but with no blocks placed
there, the regions still contain whitespace text nodes from the Twig
loop scaffolding, which defeats the `:not(:empty)` check. Result: an
empty top bar renders anyway, with the page content pushed down to make
room for it. We only use the Gin sidebar (the left icon rail), so force
the phantom bar off and collapse the space it would otherwise reserve. */
.top-bar {
display: none !important;
/* Grand livre / Dashboard switcher, top-right of both pages. Only present
on those two custom routes, but attached here rather than duplicated
between home.css and dashboard.css -- and defined with its own explicit
colors rather than relying on either page's #figli-*-app-scoped custom
properties, since this <nav> sits outside both of those elements. */
.figli-page-nav {
display: flex;
justify-content: flex-end;
gap: 0.4rem;
padding: 0.5rem 0.25rem 0;
}
.figli-page-nav a {
padding: 0.3rem 0.8rem;
border-radius: 6px;
font-size: 0.82rem;
font-weight: 600;
text-decoration: none;
color: #6b7280;
border: 1px solid transparent;
}
.figli-page-nav a:hover {
border-color: #dcdee2;
}
.figli-page-nav a.is-active {
background: #eef1f6;
color: #1a1a1a;
}
html.gin--dark-mode .figli-page-nav a {
color: #a1a5ab;
}
html.gin--dark-mode .figli-page-nav a:hover {
border-color: #3d3e42;
}
html.gin--dark-mode .figli-page-nav a.is-active {
background: #2c2d30;
color: #e8e9ea;
}
.top-bar ~ .dialog-off-canvas-main-canvas {
margin-block-start: 0 !important;
/* "Lier à une entrée client" modal (LinkEntreeForm): one autocomplete
field per linked entrée instead of a single comma-separated field --
tighten the default spacing between them a little. */
#figli-entree-liee-items .form-item {
margin-bottom: 0.5rem;
}
/* Drupal status messages: fixed top-right, out of document flow, so they
never shift the page layout the way the default in-flow placement does
-- and shrunk to a fraction of Gin's default size, which is sized for a
single full-width flash banner on an admin form, not a toast for
frequent background feedback here. Targets both the real region
([data-drupal-messages], present once a message is queued on a normal
page load) and the JS-only fallback container
([data-drupal-messages-fallback], used when Drupal.Message.add() -- our
own MessageCommand-driven AJAX messages included -- has no region to
attach to). Auto-dismiss timing for non-error messages is handled in
admin-chrome.js. */
[data-drupal-messages],
[data-drupal-messages-fallback] {
position: fixed !important;
top: 0.75rem;
right: 0.75rem;
left: auto !important;
width: auto;
max-width: 22rem;
z-index: 1000;
}
[data-drupal-messages] .messages-list__wrapper,
[data-drupal-messages] .messages__wrapper,
[data-drupal-messages-fallback] .messages-list__wrapper,
[data-drupal-messages-fallback] .messages__wrapper {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
[data-drupal-messages] .messages,
[data-drupal-messages-fallback] .messages {
padding: 0.5rem 0.6rem;
font-size: 0.78rem;
line-height: 1.3;
border-radius: 6px;
}
[data-drupal-messages] .messages__header,
[data-drupal-messages-fallback] .messages__header {
margin-block-end: 0.15rem;
margin-inline-end: 1.1em;
}
[data-drupal-messages] .messages__header::before,
[data-drupal-messages-fallback] .messages__header::before {
width: 1rem !important;
height: 1rem !important;
}
[data-drupal-messages] .messages__title,
[data-drupal-messages-fallback] .messages__title {
font-size: 0.78rem;
}
[data-drupal-messages] .messages__title,
[data-drupal-messages] .messages__content,
[data-drupal-messages-fallback] .messages__title,
[data-drupal-messages-fallback] .messages__content {
margin-inline-start: 1.4rem;
}
[data-drupal-messages] .button--dismiss,
[data-drupal-messages-fallback] .button--dismiss {
height: 20px !important;
width: 20px !important;
margin: 0.3rem !important;
}
[data-drupal-messages] .button--dismiss .icon-close,
[data-drupal-messages-fallback] .button--dismiss .icon-close {
mask-size: 11px 11px !important;
}
@@ -8,6 +8,7 @@
--figli-border: #dcdee2;
--figli-error: #c9312b;
--figli-positive: #1a7f37;
--figli-warning: #b8860b;
font-family: Inter, -apple-system, sans-serif;
max-width: 1200px;
@@ -23,54 +24,22 @@ html.gin--dark-mode #figli-dashboard-app {
--figli-border: #3d3e42;
--figli-error: #ff6b6b;
--figli-positive: #4ade80;
--figli-warning: #f0b429;
}
#figli-dashboard-app h2 {
font-size: 1.25rem;
font-size: 1.1rem;
font-weight: 600;
margin: 2rem 0 0.75rem;
margin: 0 0 0.25rem;
color: var(--figli-text);
}
#figli-dashboard-app .figli-note {
color: var(--figli-text-light);
font-size: 0.875rem;
font-size: 0.8rem;
margin-bottom: 1rem;
}
#figli-dashboard-app table {
width: 100%;
border-collapse: collapse;
background: var(--figli-bg);
color: var(--figli-text);
border: 1px solid var(--figli-border);
border-radius: 6px;
overflow: hidden;
}
#figli-dashboard-app th,
#figli-dashboard-app td {
text-align: left;
padding: 0.5rem 0.85rem;
border-bottom: 1px solid var(--figli-border);
color: var(--figli-text);
}
#figli-dashboard-app th {
background: var(--figli-bg-alt);
font-weight: 600;
font-size: 0.85rem;
}
#figli-dashboard-app td.amount {
text-align: right;
font-variant-numeric: tabular-nums;
}
#figli-dashboard-app tr.positive td.amount { color: var(--figli-positive); }
#figli-dashboard-app tr.negative td.amount { color: var(--figli-error); }
#figli-dashboard-app tr.total td { font-weight: 700; border-top: 2px solid var(--figli-border); }
#figli-dashboard-app .figli-error {
background: #fde8e8;
border: 1px solid #f4a3a3;
@@ -78,3 +47,448 @@ html.gin--dark-mode #figli-dashboard-app {
padding: 0.75rem 1rem;
border-radius: 6px;
}
/* --- Summary cards --- */
#figli-dashboard-app .figli-summary-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 2rem;
}
#figli-dashboard-app .figli-summary-card {
flex: 1 1 200px;
background: var(--figli-bg-alt);
border: 1px solid var(--figli-border);
border-radius: 8px;
padding: 0.85rem 1rem;
}
#figli-dashboard-app .figli-summary-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--figli-text-light);
margin-bottom: 0.3rem;
}
#figli-dashboard-app .figli-summary-value {
font-size: 1.4rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
#figli-dashboard-app .figli-summary-value.is-positive { color: var(--figli-positive); }
#figli-dashboard-app .figli-summary-value.is-negative { color: var(--figli-error); }
#figli-dashboard-app .figli-summary-card.is-alert { border-color: var(--figli-error); }
/* --- Compte selector (dashboard par compte) --- */
#figli-dashboard-app .figli-compte-selector {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 1.25rem;
}
#figli-dashboard-app .figli-compte-selector label {
font-size: 0.85rem;
font-weight: 600;
color: var(--figli-text-light);
}
#figli-dashboard-app .figli-compte-selector select {
font-size: 1rem;
font-weight: 600;
padding: 0.4rem 0.6rem;
border: 1px solid var(--figli-border);
border-radius: 6px;
background: var(--figli-bg);
color: var(--figli-text);
}
/* Signalement filter (Lignes signalées section) -- same multiselect
widget as /lignes' Compte/Type filters (home.css), duplicated here
rather than shared, see this project's established convention for
dashboard.js/dashboard-compte.js vs home.js. */
#figli-dashboard-app .figli-filter-row {
display: flex;
align-items: center;
gap: 0.25rem;
}
#figli-dashboard-app .figli-filter-clear {
background: transparent;
border: none;
color: var(--figli-text-light);
cursor: pointer;
font-size: 0.8rem;
line-height: 1;
padding: 0.15rem;
}
#figli-dashboard-app .figli-filter-clear:hover {
color: var(--figli-error);
}
#figli-dashboard-app .figli-multiselect {
position: relative;
}
#figli-dashboard-app .figli-multiselect-trigger {
font-size: 0.85rem;
padding: 0.2rem 0.4rem;
background: var(--figli-bg);
color: var(--figli-text);
border: 1px solid var(--figli-border);
border-radius: 4px;
width: 12rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: default;
}
#figli-dashboard-app .figli-multiselect-arrow {
float: right;
color: var(--figli-text-light);
}
#figli-dashboard-app .figli-multiselect-panel {
display: none;
position: absolute;
top: 100%;
left: 0;
z-index: 20;
min-width: 100%;
white-space: nowrap;
background: var(--figli-bg);
color: var(--figli-text);
border: 1px solid var(--figli-border);
border-radius: 4px;
padding: 0.3rem 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
#figli-dashboard-app .figli-multiselect:hover .figli-multiselect-panel {
display: block;
}
#figli-dashboard-app .figli-multiselect-option {
display: flex !important;
flex-direction: row !important;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
font-weight: 400;
padding: 0.15rem 0;
}
/* --- Reconciliation tables (reste à verser, sur-versé, non liés) --- */
#figli-dashboard-app .figli-recon-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
#figli-dashboard-app .figli-recon-table th {
text-align: left;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--figli-text-light);
padding: 0.3rem 0.6rem;
border-bottom: 1px solid var(--figli-border);
}
#figli-dashboard-app .figli-recon-table td {
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--figli-border);
font-variant-numeric: tabular-nums;
}
#figli-dashboard-app .figli-recon-table td.is-negative { color: var(--figli-error); font-weight: 600; }
#figli-dashboard-app .figli-recon-table tbody tr:hover { background: var(--figli-bg-alt); }
#figli-dashboard-app .figli-recon-table tfoot td { font-weight: 700; border-bottom: none; border-top: 2px solid var(--figli-border); }
#figli-dashboard-app .figli-recon-table a { color: inherit; text-decoration: underline; text-decoration-color: var(--figli-border); }
/* Signalée rows: same amber left-edge accent as /lignes (box-shadow, not
border, so it doesn't fight the row's existing bottom border). */
#figli-dashboard-app .figli-recon-table tr.figli-flag-row td:first-child {
box-shadow: inset 4px 0 0 var(--figli-warning);
}
#figli-dashboard-app .figli-flag-badge {
display: inline-block;
padding: 0.05rem 0.4rem;
margin: 0 0 0 0.4rem;
border-radius: 8px;
font-size: 0.68rem;
font-weight: 600;
background: color-mix(in srgb, var(--figli-warning) 15%, transparent);
color: var(--figli-warning);
white-space: nowrap;
}
/* --- Year bars chart (évolution du solde, entrées vs versements) --- */
#figli-dashboard-app .figli-year-chart {
display: flex;
align-items: stretch;
gap: 0.5rem;
height: 14rem;
}
#figli-dashboard-app .figli-year-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
min-width: 1.5rem;
}
#figli-dashboard-app .figli-year-track {
position: relative;
flex: 1;
width: 100%;
}
#figli-dashboard-app .figli-year-track::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: var(--figli-border);
}
#figli-dashboard-app .figli-year-bars {
position: absolute;
inset: 0;
display: flex;
justify-content: center;
gap: 2px;
}
/* Slot stays in normal flex flow (so justify-content/gap on
.figli-year-bars above still lays several bars out side by side) and
gives the bar a definite height (100% of the track) to anchor
against. The bar itself must be position: absolute, not relative --
with relative, an explicit height opts it out of the flex stretch
default, so it renders flex-start (top) aligned *first*, and the
top/bottom: 50% from barStyle() only *offsets* that already-top
position instead of anchoring the edge to the middle -- the visible
bug this replaces (bars pinned near the top instead of growing from
the zero line). */
#figli-dashboard-app .figli-year-bar-slot {
position: relative;
width: 0.9rem;
height: 100%;
}
#figli-dashboard-app .figli-year-bar {
position: absolute;
left: 0;
right: 0;
min-height: 2px;
border-radius: 2px;
}
@media (prefers-reduced-motion: no-preference) {
#figli-dashboard-app .figli-year-bar {
transition: height 0.3s ease;
}
}
#figli-dashboard-app .figli-year-label {
font-size: 0.78rem;
color: var(--figli-text-light);
margin-top: 0.4rem;
}
/* --- Chart sections --- */
#figli-dashboard-app .figli-chart-section {
background: var(--figli-bg);
border: 1px solid var(--figli-border);
border-radius: 8px;
padding: 1.25rem 1.5rem 1.5rem;
margin-bottom: 1.5rem;
}
/* --- Horizontal bar chart (solde par compte, par type, top clients) --- */
#figli-dashboard-app .figli-hbar-chart {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
#figli-dashboard-app .figli-hbar-row {
display: grid;
grid-template-columns: 11rem 1fr 7rem;
align-items: center;
gap: 0.75rem;
}
#figli-dashboard-app .figli-hbar-label {
font-size: 0.82rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#figli-dashboard-app .figli-hbar-track {
position: relative;
height: 0.9rem;
background: var(--figli-bg-alt);
border-radius: 4px;
overflow: hidden;
}
#figli-dashboard-app .figli-hbar-track.is-diverging {
overflow: visible;
}
#figli-dashboard-app .figli-hbar-zero {
position: absolute;
top: -2px;
bottom: -2px;
left: 50%;
width: 1px;
background: var(--figli-border);
}
#figli-dashboard-app .figli-hbar-fill {
position: absolute;
top: 0;
bottom: 0;
border-radius: 3px;
min-width: 2px;
}
@media (prefers-reduced-motion: no-preference) {
#figli-dashboard-app .figli-hbar-fill {
transition: width 0.3s ease;
}
}
#figli-dashboard-app .figli-hbar-value {
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
text-align: right;
white-space: nowrap;
}
/* Compact variant -- same chart, narrower fixed columns and smaller
text so it fits inside a small-multiples grid card (see
.figli-year-hbar-grid below) instead of the full-width layout. */
#figli-dashboard-app .figli-hbar-chart.is-compact {
gap: 0.3rem;
}
#figli-dashboard-app .figli-hbar-chart.is-compact .figli-hbar-row {
grid-template-columns: 5.5rem 1fr 3.5rem;
gap: 0.4rem;
}
#figli-dashboard-app .figli-hbar-chart.is-compact .figli-hbar-label {
font-size: 0.72rem;
}
#figli-dashboard-app .figli-hbar-chart.is-compact .figli-hbar-track {
height: 0.65rem;
}
#figli-dashboard-app .figli-hbar-chart.is-compact .figli-hbar-value {
font-size: 0.7rem;
}
/* --- Year small multiples (par année, next to the all-time chart) --- */
#figli-dashboard-app .figli-year-hbar-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: 0.75rem;
margin-top: 1.25rem;
padding-top: 1.25rem;
border-top: 1px solid var(--figli-border);
}
#figli-dashboard-app .figli-year-hbar-card {
background: var(--figli-bg-alt);
border: 1px solid var(--figli-border);
border-radius: 6px;
padding: 0.65rem 0.75rem;
}
#figli-dashboard-app .figli-year-hbar-title {
font-size: 0.78rem;
font-weight: 700;
color: var(--figli-text-light);
margin-bottom: 0.5rem;
}
/* --- Vertical bar chart (CA par année) --- */
#figli-dashboard-app .figli-vbar-chart {
display: flex;
align-items: flex-end;
gap: 1rem;
height: 12rem;
padding-top: 1.5rem;
}
#figli-dashboard-app .figli-vbar-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
}
#figli-dashboard-app .figli-vbar-value {
font-size: 0.75rem;
font-weight: 600;
margin-bottom: 0.3rem;
white-space: nowrap;
}
#figli-dashboard-app .figli-vbar-track {
flex: 1;
width: 100%;
display: flex;
align-items: flex-end;
}
#figli-dashboard-app .figli-vbar-fill {
width: 100%;
background: var(--figli-positive);
border-radius: 4px 4px 0 0;
min-height: 2px;
}
@media (prefers-reduced-motion: no-preference) {
#figli-dashboard-app .figli-vbar-fill {
transition: height 0.3s ease;
}
}
#figli-dashboard-app .figli-vbar-label {
font-size: 0.78rem;
color: var(--figli-text-light);
margin-top: 0.4rem;
}
/* --- Trend grid (small multiples, one per compte) --- */
#figli-dashboard-app .figli-trend-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
gap: 1rem;
}
#figli-dashboard-app .figli-trend-card {
background: var(--figli-bg-alt);
border: 1px solid var(--figli-border);
border-radius: 6px;
padding: 0.75rem;
}
#figli-dashboard-app .figli-trend-title {
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
#figli-dashboard-app .figli-mini-trend {
display: flex;
align-items: stretch;
gap: 0.2rem;
height: 4.5rem;
}
#figli-dashboard-app .figli-mini-bar-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
#figli-dashboard-app .figli-mini-bar-track {
position: relative;
flex: 1;
width: 100%;
}
#figli-dashboard-app .figli-mini-bar-track::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: var(--figli-border);
}
#figli-dashboard-app .figli-mini-bar-fill {
position: absolute;
left: 1px;
right: 1px;
border-radius: 2px;
}
#figli-dashboard-app .figli-mini-bar-fill.is-positive {
bottom: 50%;
background: var(--figli-positive);
}
#figli-dashboard-app .figli-mini-bar-fill.is-negative {
top: 50%;
background: var(--figli-error);
}
#figli-dashboard-app .figli-mini-bar-label {
font-size: 0.65rem;
color: var(--figli-text-light);
margin-top: 0.25rem;
}
@@ -0,0 +1,28 @@
/* Only attached on this module's own front-end routes (see
figli_compta_ledger_page_attachments()) -- real Drupal admin pages
(/admin/*, node edit forms, etc.) keep all of this as normal.
Previously the top-bar rule below was unconditional (attached on every
page via admin_chrome), on the assumption that core Navigation's top
bar (`.top-bar`) always renders empty -- true on our own pages (no
blocks placed there, so whitespace text nodes from the Twig loop
scaffolding defeat its own `:not(:empty)` visibility check), but wrong
on entity edit forms, where Gin puts the page's local actions (the
Save button included) *inside* `.top-bar` -- hiding it there hid Save
itself, not just an empty bar. */
.top-bar {
display: none !important;
}
.top-bar ~ .dialog-off-canvas-main-canvas {
margin-block-start: 0 !important;
}
/* The core Navigation module's admin sidebar (#admin-toolbar, ~65px fixed
rail) + its collapsed-state control bar and overlay. */
#admin-toolbar,
.admin-toolbar-control-bar,
.admin-toolbar-overlay {
display: none !important;
}
.dialog-off-canvas-main-canvas {
margin-inline-start: 0 !important;
}
@@ -9,6 +9,7 @@
--figli-border: #dcdee2;
--figli-error: #c9312b;
--figli-positive: #1a7f37;
--figli-warning: #b8860b;
--figli-col-hover: rgba(15, 23, 42, 0.05);
font-family: Inter, -apple-system, sans-serif;
@@ -24,6 +25,7 @@ html.gin--dark-mode #figli-home-app {
--figli-border: #3d3e42;
--figli-error: #ff6b6b;
--figli-positive: #4ade80;
--figli-warning: #f0b429;
--figli-col-hover: rgba(255, 255, 255, 0.07);
}
@@ -58,6 +60,91 @@ html.gin--dark-mode #figli-home-app {
border-radius: 4px;
}
/* Each filter control + its "✕ effacer" button, side by side. */
#figli-home-app .figli-filter-row {
display: flex;
align-items: center;
gap: 0.25rem;
}
#figli-home-app .figli-filter-clear {
background: transparent;
border: none;
color: var(--figli-text-light);
cursor: pointer;
font-size: 0.8rem;
line-height: 1;
padding: 0.15rem;
}
#figli-home-app .figli-filter-clear:hover {
color: var(--figli-error);
}
/* Compte/Type filters: a native <select multiple> can't collapse to a
single-line trigger and expand as an overlay -- it's either a
single-line dropdown, or an always-open multi-row list box. Custom
dropdown instead: a trigger showing the current selection, and a
checkbox list overlay shown on hover (pure CSS, no open/close state
needed) -- checkboxes rather than ctrl/shift+click, but same result
(several comptes/types at once) with an even more discoverable
interaction (no keyboard modifier to know about).*/
#figli-home-app .figli-multiselect {
position: relative;
}
#figli-home-app .figli-multiselect-trigger {
font-size: 0.85rem;
padding: 0.2rem 0.4rem;
background: var(--figli-bg);
color: var(--figli-text);
border: 1px solid var(--figli-border);
border-radius: 4px;
width: 9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: default;
}
#figli-home-app .figli-multiselect-arrow {
float: right;
color: var(--figli-text-light);
}
#figli-home-app .figli-multiselect-panel {
display: none;
position: absolute;
top: 100%;
left: 0;
z-index: 20;
min-width: 100%;
white-space: nowrap;
background: var(--figli-bg);
color: var(--figli-text);
border: 1px solid var(--figli-border);
border-radius: 4px;
padding: 0.3rem 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
#figli-home-app .figli-multiselect:hover .figli-multiselect-panel {
display: block;
}
#figli-home-app .figli-multiselect-option {
display: flex !important;
flex-direction: row !important;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
font-weight: 400;
padding: 0.15rem 0;
}
#figli-home-app .figli-client-input {
font-size: 0.85rem;
padding: 0.2rem 0.4rem;
background: var(--figli-bg);
color: var(--figli-text);
border: 1px solid var(--figli-border);
border-radius: 4px;
width: 12rem;
}
#figli-home-app .figli-checkbox {
flex-direction: row !important;
align-items: center;
@@ -72,24 +159,49 @@ html.gin--dark-mode #figli-home-app {
}
#figli-home-app .figli-table-wrap {
overflow-x: auto;
/* Fixed columns below are sized to always fit -- hidden (not auto) so
there's never a horizontal scrollbar to fight the vertical one for
layout space. With both set to auto, the browser has to guess
whether a vertical scrollbar will appear before it can lay out the
table's width:100%, and getting that guess wrong understates the
available width by one scrollbar's worth, which was enough to tip
this from "just barely fits" into "needs to scroll horizontally
too" -- and any actual residual overflow (a column pushed a few px
over by content this doesn't attempt to break) is silently clipped
here instead of surfacing a scrollbar for it. */
overflow-x: hidden;
border: 1px solid var(--figli-border);
border-radius: 6px;
max-height: 75vh;
overflow-y: auto;
}
/* table-layout: fixed -- auto layout was recomputing every column's
width from whatever happened to be currently loaded, so the table
visibly reflowed (and drifted wider than the viewport, forcing
horizontal scroll) every time loadOlder()/loadNewer() brought in rows
with different content. Fixed layout locks each column to the widths
set below regardless of content, so the table's total width never
changes -- no more horizontal scroll, and loadOlder()'s scroll
compensation (which assumes the height delta after prepending is
*only* the new rows' own height) is now accurate again, since existing
rows no longer reflow when new ones are added. */
#figli-home-app table {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
background: var(--figli-bg);
color: var(--figli-text);
font-size: 0.8rem;
white-space: nowrap;
}
#figli-home-app th,
#figli-home-app td {
/* border-box -- otherwise each column's percentage width (fixed
layout, above) sets only the content box, and this cell's own
padding/border get added on top of it, so 18 columns' worth of
padding quietly pushes the table wider than 100% again. */
box-sizing: border-box;
text-align: left;
padding: 0.3rem 0.6rem;
border-bottom: 1px solid var(--figli-border);
@@ -114,10 +226,16 @@ html.gin--dark-mode #figli-home-app {
font-variant-numeric: tabular-nums;
}
#figli-home-app th:nth-child(6),
#figli-home-app td.figli-libelle {
width: 10%;
white-space: normal;
}
#figli-home-app th:nth-child(7),
#figli-home-app td.figli-flag-cell {
width: 6%;
white-space: normal;
min-width: 220px;
max-width: 340px;
}
#figli-home-app tr.figli-group-row td {
@@ -152,6 +270,15 @@ html.gin--dark-mode #figli-home-app {
#figli-home-app td.figli-solde-debiteur {
color: var(--figli-error);
}
/* Footer totals row sets its own `color` (higher specificity: tr.class +
td beats a lone td.class) which otherwise overrides the solde colors
above -- repeat them scoped to the row so red/green still wins there. */
#figli-home-app tr.figli-totals-row td.figli-solde-crediteur {
color: var(--figli-positive);
}
#figli-home-app tr.figli-totals-row td.figli-solde-debiteur {
color: var(--figli-error);
}
/* Single colored + bold anchor per row (Montant HT only) so entrées vs
sorties are scannable at a glance -- everything else in the row (TTC,
@@ -165,24 +292,30 @@ html.gin--dark-mode #figli-home-app {
font-weight: 700;
}
/* Error rows: a thin red outline around the row, not a background fill --
easier to read, doesn't fight with dark mode. */
#figli-home-app tr.figli-error-row td {
border-top: 1px solid var(--figli-error);
border-bottom: 1px solid var(--figli-error);
}
#figli-home-app tr.figli-error-row td:first-child {
border-left: 1px solid var(--figli-error);
}
#figli-home-app tr.figli-error-row td:last-child {
border-right: 1px solid var(--figli-error);
}
/* Écart is called out by the Écart column itself (bold red, see
.figli-ecart below) -- no separate row-level treatment needed. */
#figli-home-app td.figli-ecart {
color: var(--figli-error);
font-weight: 700;
}
/* Signalé rows: a thick amber accent bar on the left edge. */
#figli-home-app tr.figli-flag-row td:first-child {
box-shadow: inset 4px 0 0 var(--figli-warning);
}
#figli-home-app .figli-flag-badge {
display: inline-block;
padding: 0.05rem 0.4rem;
margin: 0 0.2rem 0.15rem 0;
border-radius: 8px;
font-size: 0.68rem;
font-weight: 600;
background: color-mix(in srgb, var(--figli-warning) 15%, transparent);
color: var(--figli-warning);
white-space: nowrap;
}
/* Column highlight to pair with the row hover, forming a crosshair over
the hovered cell. box-shadow (not background) so it layers on top of
whatever the cell already has -- sticky header/footer backgrounds,
@@ -206,8 +339,56 @@ html.gin--dark-mode #figli-home-app {
#figli-home-app .type-achat { background: #1d4ed81a; color: #3b6fe0; }
#figli-home-app .type-hebergement { background: #0e91821a; color: #0e9182; }
#figli-home-app .type-ouverture { background: #7c3aed1a; color: #9061f0; }
#figli-home-app .type-sous_traitant { background: #c9312b1a; color: #c9312b; }
#figli-home-app .type-salaire_stage { background: #0891b21a; color: #0891b2; }
#figli-home-app .type-charges_local_pro { background: #65a30d1a; color: #65a30d; }
#figli-home-app .type-autre { background: #6b72801a; color: var(--figli-text-light); }
/* Click-to-edit type badge -- swaps for a native <select> in place
(figli-type-select below), no modal needed for this one field. */
#figli-home-app .figli-badge-editable {
cursor: pointer;
border: 1px solid transparent;
}
#figli-home-app .figli-badge-editable:hover {
border-color: currentColor;
}
#figli-home-app .figli-type-select {
font-size: 0.72rem;
padding: 0.05rem 0.2rem;
background: var(--figli-bg);
color: var(--figli-text);
border: 1px solid var(--figli-border);
border-radius: 4px;
}
/* Client/Facture/Libellé: click-to-edit like the type badge above, but
plain text rather than a pill -- a dotted underline is enough of an
affordance without implying a fixed set of choices the way the type
badge's pill shape does. */
/* display: block + min-height (not just the default inline text-only
box) so the whole cell is a forgiving click target even when empty --
the "—" placeholder alone is a tiny, easy-to-miss target otherwise. */
#figli-home-app .figli-editable-cell {
display: block;
min-height: 1.2em;
cursor: pointer;
border-bottom: 1px dotted transparent;
}
#figli-home-app .figli-editable-cell:hover {
border-bottom-color: var(--figli-text-light);
}
#figli-home-app .figli-inline-input {
font-size: 0.8rem;
padding: 0.1rem 0.3rem;
background: var(--figli-bg);
color: var(--figli-text);
border: 1px solid var(--figli-border);
border-radius: 4px;
width: 100%;
box-sizing: border-box;
}
#figli-home-app .figli-note {
color: var(--figli-text-light);
font-weight: 400;
@@ -222,18 +403,57 @@ html.gin--dark-mode #figli-home-app {
table's default 0.6rem horizontal cell padding. */
#figli-home-app td.actions-col,
#figli-home-app th.actions-col {
width: 1%;
width: 3%;
padding: 0.2rem 0.1rem;
text-align: center;
white-space: nowrap;
}
/* Column widths for the fixed layout above -- sized so the widest
realistic content (longest client/type label, an 8-figure amount)
fits without pushing the table past 100%, while the two free-text
columns (Client, Libellé/Détail) get most of the remaining room.
Every column is a percentage (including actions-col/Date above,
deliberately not rem) -- mixing units meant the rem columns' pixel
width was added *on top of* the percentage budget instead of coming
out of it, silently pushing the table a few pixels past 100% and
reintroducing the horizontal scrollbar this is meant to avoid.
Percentages intentionally sum to a little under 100%: table-layout:
fixed treats them as relative weights, not a hard budget, so slightly
under leaves headroom rather than risking every column getting
scaled down to fit. */
#figli-home-app th:nth-child(2),
#figli-home-app td:nth-child(2) {
width: 5%;
white-space: nowrap;
}
#figli-home-app th:nth-child(3),
#figli-home-app td:nth-child(3) {
width: 10%;
}
#figli-home-app th:nth-child(4),
#figli-home-app td:nth-child(4) {
width: 7%;
}
#figli-home-app th:nth-child(5),
#figli-home-app td:nth-child(5) {
width: 5%;
white-space: nowrap;
}
#figli-home-app .amount:not(.compte-col) {
width: 6.2%;
white-space: nowrap;
}
#figli-home-app .amount.compte-col {
width: 4.3%;
white-space: nowrap;
}
/* Buttons stay inline (not flex) inside the <td> -- display: flex on a
table cell breaks the table's own column-width calculation (observed:
a ~55px gap opening up between this column and the next, and the
buttons rendering outside their cell's actual boundary). */
#figli-home-app .figli-edit-btn,
#figli-home-app .figli-link-btn {
#figli-home-app .figli-edit-btn {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -248,17 +468,12 @@ html.gin--dark-mode #figli-home-app {
vertical-align: middle;
}
#figli-home-app .figli-edit-btn:hover,
#figli-home-app .figli-link-btn:hover {
#figli-home-app .figli-edit-btn:hover {
background: var(--figli-bg-alt);
border-color: var(--figli-border);
color: var(--figli-text);
}
#figli-home-app .figli-link-btn.is-linked {
color: #3b6fe0;
}
/* Reconciliation badge on entrée rows: répartition (money owed) vs the
combined répartition of every linked sortie (money actually paid out). */
#figli-home-app .figli-recon-badge {
@@ -269,13 +484,21 @@ html.gin--dark-mode #figli-home-app {
font-size: 0.68rem;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
background: #1a7f371a;
color: var(--figli-positive);
}
/* Not every badge using this class does something on click (the
ouverture/clôture écart badge is purely informational) -- only show
the pointer cursor where a click actually goes somewhere. */
#figli-home-app .figli-recon-badge.is-clickable {
cursor: pointer;
}
/* Still green, not orange -- it's money owed, not a warning, and
green-but-darker reads as "related to, but not yet, Lié" (the default
badge green above) rather than as an unrelated anomaly color. */
#figli-home-app .figli-recon-badge.is-reste {
background: #d97a0a1a;
color: #d97a0a;
background: #1665341a;
color: #166534;
}
#figli-home-app .figli-recon-badge.is-anomalie {
background: #c9312b1a;
@@ -287,6 +510,166 @@ html.gin--dark-mode #figli-home-app {
padding: 0.2rem 0.6rem;
}
/* Entrée + sorties liées drill-down: a modal overlay rather than
replacing the main table's rows in place, so opening/closing it never
disturbs the main table's scroll position. */
#figli-home-app .figli-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
#figli-home-app .figli-modal {
background: var(--figli-bg);
color: var(--figli-text);
border: 1px solid var(--figli-border);
border-radius: 8px;
width: 96vw;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
#figli-home-app .figli-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--figli-border);
}
#figli-home-app .figli-modal-header h3 {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
}
#figli-home-app .figli-modal-close {
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: var(--figli-text-light);
cursor: pointer;
font-size: 0.9rem;
padding: 0.15rem 0.45rem;
}
#figli-home-app .figli-modal-close:hover {
background: var(--figli-bg-alt);
border-color: var(--figli-border);
color: var(--figli-text);
}
/* Signalement modal -- much smaller than the entrées/sorties drill-down
above (a short list of tags, not a table), so it gets its own narrow
width instead of the 96vw default. */
#figli-home-app .figli-flag-modal {
width: 24rem;
max-width: 90vw;
}
#figli-home-app .figli-flag-modal-body {
padding: 0.75rem 1rem 1rem;
}
#figli-home-app .figli-flag-modal-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.35rem 0;
border-bottom: 1px solid var(--figli-border);
}
#figli-home-app .figli-flag-remove {
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: var(--figli-text-light);
cursor: pointer;
font-size: 0.8rem;
padding: 0.1rem 0.4rem;
flex: none;
}
#figli-home-app .figli-flag-remove:hover {
background: var(--figli-bg-alt);
border-color: var(--figli-border);
color: var(--figli-error);
}
#figli-home-app .figli-flag-modal-add {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
#figli-home-app .figli-flag-modal-add input {
flex: 1;
font-size: 0.85rem;
padding: 0.3rem 0.5rem;
border: 1px solid var(--figli-border);
border-radius: 4px;
background: var(--figli-bg);
color: var(--figli-text);
}
#figli-home-app .figli-modal-body {
overflow-y: auto;
overflow-x: hidden;
padding: 0;
}
#figli-home-app .figli-modal-body table {
font-size: 0.8rem;
table-layout: fixed;
width: 100%;
/* Only a handful of rows in this view -- wrap instead of the main
table's nowrap, so 14 columns fit the modal width without ever
needing horizontal scroll. */
white-space: normal;
}
#figli-home-app .figli-modal-body thead th {
position: sticky;
top: 0;
}
/* table-layout: fixed otherwise spreads all 16 columns evenly, squeezing
the libellé text into the same width as an empty compte column --
give the columns that actually carry content (client/type/libellé)
proportionally more room, comptes/montants less. */
#figli-home-app .figli-modal-body th:nth-child(1),
#figli-home-app .figli-modal-body td:nth-child(1) {
width: 2rem;
}
#figli-home-app .figli-modal-body th:nth-child(2),
#figli-home-app .figli-modal-body td:nth-child(2) {
width: 4.5rem;
}
#figli-home-app .figli-modal-body th:nth-child(3),
#figli-home-app .figli-modal-body td:nth-child(3) {
width: 9%;
}
#figli-home-app .figli-modal-body th:nth-child(4),
#figli-home-app .figli-modal-body td:nth-child(4) {
width: 8%;
}
#figli-home-app .figli-modal-body th:nth-child(5),
#figli-home-app .figli-modal-body td.figli-libelle {
width: 24%;
min-width: 0;
max-width: none;
}
#figli-home-app .figli-modal-body .amount.compte-col {
width: 4.5%;
}
#figli-home-app .figli-modal-body .amount:not(.compte-col) {
width: 7%;
}
/* Sliding-window edge markers (IntersectionObserver targets) -- kept
short so they don't add visible dead space when idle, tall enough
(min-height) to reliably intersect the observer's root margin. */
#figli-home-app tr.figli-sentinel-row td {
padding: 0.3rem 0.6rem;
min-height: 1.5rem;
text-align: center;
color: var(--figli-text-light);
font-size: 0.75rem;
font-style: italic;
}
#figli-home-app .figli-error {
background: #fde8e8;
border: 1px solid #f4a3a3;
@@ -294,3 +677,14 @@ html.gin--dark-mode #figli-home-app {
padding: 0.75rem 1rem;
border-radius: 6px;
}
/* Dismissible, doesn't replace the table like the top-level fetch error
does -- a failed inline type change is a small hiccup, not a reason to
hide everything that's already loaded. */
#figli-home-app .figli-inline-error {
margin-bottom: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
@@ -46,6 +46,9 @@ function _figli_compta_ledger_clients() {
function figli_compta_ledger_install() {
_figli_compta_ledger_create_vocabulary('compte', 'Compte', _figli_compta_ledger_comptes());
_figli_compta_ledger_create_vocabulary('client', 'Client', _figli_compta_ledger_clients());
// No starter terms -- unlike compte/client, flags are created on the fly
// as they're needed (see field_flag's auto_create handler setting below).
_figli_compta_ledger_create_vocabulary('flag', 'Signalement', []);
_figli_compta_ledger_create_paragraph_repartition();
_figli_compta_ledger_create_node_type_ligne_comptable();
}
@@ -87,16 +90,25 @@ function _figli_field($entity_type, $bundle, $field_name, $label, $type, array $
}
}
function _figli_entity_ref_field($entity_type, $bundle, $field_name, $label, $target_type, $target_bundle, $required = FALSE) {
function _figli_entity_ref_field($entity_type, $bundle, $field_name, $label, $target_type, $target_bundle, $required = FALSE, $cardinality = 1, $auto_create = FALSE) {
if (!FieldStorageConfig::loadByName($entity_type, $field_name)) {
FieldStorageConfig::create([
'field_name' => $field_name,
'entity_type' => $entity_type,
'type' => 'entity_reference',
'cardinality' => $cardinality,
'settings' => ['target_type' => $target_type],
])->save();
}
if (!FieldConfig::loadByName($entity_type, $bundle, $field_name)) {
$handler_settings = ['target_bundles' => [$target_bundle => $target_bundle]];
// Lets the tags-style widget create a new term on the fly instead of
// rejecting anything not already in the vocabulary -- same "autocreate"
// behavior LedgerActionsController::updateField() already replicates
// by hand for the /lignes inline-edit endpoint (client, flag).
if ($auto_create) {
$handler_settings['auto_create'] = TRUE;
}
FieldConfig::create([
'field_name' => $field_name,
'entity_type' => $entity_type,
@@ -105,7 +117,7 @@ function _figli_entity_ref_field($entity_type, $bundle, $field_name, $label, $ta
'required' => $required,
'settings' => [
'handler' => 'default:' . $target_type,
'handler_settings' => ['target_bundles' => [$target_bundle => $target_bundle]],
'handler_settings' => $handler_settings,
],
])->save();
}
@@ -188,15 +200,28 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
'hebergement' => 'Hébergement',
'autre' => 'Autre',
'ouverture' => "Ligne d'ouverture",
'sous_traitant' => 'Sous-traitant',
'salaire_stage' => 'Salaire / stage',
'charges_local_pro' => 'Charges local pro',
],
], TRUE);
_figli_entity_ref_field('node', 'ligne_comptable', 'field_client', 'Client', 'taxonomy_term', 'client');
_figli_field('node', 'ligne_comptable', 'field_numero_facture', 'N° Facture', 'string', ['max_length' => 64]);
_figli_field('node', 'ligne_comptable', 'field_montant_ht', 'Montant HT (€)', 'decimal', ['precision' => 12, 'scale' => 2], TRUE);
_figli_field('node', 'ligne_comptable', 'field_montant_ttc', 'Montant TTC (€)', 'decimal', ['precision' => 12, 'scale' => 2]);
_figli_field('node', 'ligne_comptable', 'field_notes', 'Notes / détail', 'string_long');
// Free-tagging signalement (e.g. "client impayé", "à relancer") -- purely
// informational, never read by figli_compta_ledger_node_presave() or any
// total/solde calculation. Multi-value (-1) + auto_create: a line can
// carry several tags, and typing a new one creates it rather than
// rejecting it, same as the /lignes inline-edit endpoint already does
// by hand for field_client.
_figli_entity_ref_field('node', 'ligne_comptable', 'field_flag', 'Signalement', 'taxonomy_term', 'flag', FALSE, -1, TRUE);
_figli_paragraph_field('node', 'ligne_comptable', 'field_repartition', 'Répartition', 'repartition');
if (!EntityFormDisplay::load('node.ligne_comptable.default')) {
@@ -209,10 +234,12 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
->setComponent('field_date_ligne', ['type' => 'datetime_default', 'weight' => 0])
->setComponent('field_type_ligne', ['type' => 'options_select', 'weight' => 1])
->setComponent('field_client', ['type' => 'entity_reference_autocomplete', 'weight' => 2])
->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 3])
->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 4])
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 5, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']])
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 6])
->setComponent('field_numero_facture', ['type' => 'string_textfield', 'weight' => 3])
->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 4])
->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 5])
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 6, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']])
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 7])
->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 8])
->save();
}
@@ -226,10 +253,103 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
->setComponent('field_date_ligne', ['type' => 'datetime_default', 'weight' => 0])
->setComponent('field_type_ligne', ['type' => 'list_default', 'weight' => 1])
->setComponent('field_client', ['type' => 'entity_reference_label', 'weight' => 2])
->setComponent('field_montant_ht', ['type' => 'number_decimal', 'weight' => 3])
->setComponent('field_montant_ttc', ['type' => 'number_decimal', 'weight' => 4])
->setComponent('field_repartition', ['type' => 'entity_reference_revisions_entity_view', 'weight' => 5])
->setComponent('field_notes', ['type' => 'basic_string', 'weight' => 6])
->setComponent('field_numero_facture', ['type' => 'string', 'weight' => 3])
->setComponent('field_montant_ht', ['type' => 'number_decimal', 'weight' => 4])
->setComponent('field_montant_ttc', ['type' => 'number_decimal', 'weight' => 5])
->setComponent('field_repartition', ['type' => 'entity_reference_revisions_entity_view', 'weight' => 6])
->setComponent('field_notes', ['type' => 'basic_string', 'weight' => 7])
->setComponent('field_flag', ['type' => 'entity_reference_label', 'weight' => 8])
->save();
}
}
/**
* Adds field_numero_facture (N° Facture) to ligne_comptable. Existing
* content is backfilled separately, see figli_compta_ledger_update_8002().
*/
function figli_compta_ledger_update_8001() {
_figli_field('node', 'ligne_comptable', 'field_numero_facture', 'N° Facture', 'string', ['max_length' => 64]);
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
if ($form_display && !$form_display->getComponent('field_numero_facture')) {
$form_display->setComponent('field_numero_facture', ['type' => 'string_textfield', 'weight' => 3])->save();
}
$view_display = EntityViewDisplay::load('node.ligne_comptable.default');
if ($view_display && !$view_display->getComponent('field_numero_facture')) {
$view_display->setComponent('field_numero_facture', ['type' => 'string', 'weight' => 3])->save();
}
}
/**
* Backfills field_numero_facture for existing ligne_comptable content by
* extracting an invoice number from field_notes (or the title when notes
* is empty -- the same fallback the front-end libellé column uses)
* wherever one can be confidently identified, left blank otherwise
* (never guessed).
*
* Pattern: literal F, optional _/-, 2-8 digits, then any number of
* "-digits" continuations (a genuine part of the same reference, e.g.
* "F2549-50-51" or "F2025-06-002"), then 0-3 trailing uppercase letters
* (e.g. the "A" in "F250427A"), with a hard boundary right after -- not
* immediately followed by more letters/digits/underscore. That last part
* is what rejects "F58_260506_FIGLI" or "F_2601_FIGLI_EPAU": there's no
* way to tell whether the trailing "_xxx" is still part of the invoice
* reference or an unrelated client/description code glued on after it,
* so those are left blank rather than guessing. Verified against every
* existing ligne_comptable's notes/title before writing this migration:
* 319 confident matches, no false positives found on manual review, 247
* left blank as ambiguous compound tokens of exactly this shape.
*/
function figli_compta_ledger_update_8002() {
$pattern = '/(?<![A-Za-z0-9])F[_-]?\d{2,8}(?:-\d+)*[A-Z]{0,3}(?![A-Za-z0-9_])/';
$storage = \Drupal::entityTypeManager()->getStorage('node');
$nids = $storage->getQuery()
->accessCheck(FALSE)
->condition('type', 'ligne_comptable')
->execute();
$filled = 0;
$skipped = 0;
// Migrated historical data has known, deliberately-preserved
// répartition mismatches -- saving these nodes for an unrelated field
// would otherwise trip figli_compta_ledger_node_presave()'s
// sum(répartition) == montant_ht check.
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
foreach ($storage->loadMultiple($nids) as $node) {
$text = $node->hasField('field_notes') && !$node->get('field_notes')->isEmpty()
? $node->get('field_notes')->value
: $node->label();
if (!$text || !preg_match($pattern, $text, $matches)) {
$skipped++;
continue;
}
$node->set('field_numero_facture', $matches[0]);
$node->save();
$filled++;
}
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
return "Numéro de facture rempli pour $filled lignes, $skipped laissées vides (aucun motif fiable trouvé).";
}
/**
* Adds field_flag (Signalement) to ligne_comptable -- free tagging for
* problems that can't be detected automatically (e.g. "client impayé"),
* see figli_compta_ledger.module's docblock. No backfill: unlike
* field_numero_facture there's nothing to infer from existing content,
* this is new operational metadata going forward.
*/
function figli_compta_ledger_update_8003() {
_figli_compta_ledger_create_vocabulary('flag', 'Signalement', []);
_figli_entity_ref_field('node', 'ligne_comptable', 'field_flag', 'Signalement', 'taxonomy_term', 'flag', FALSE, -1, TRUE);
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
if ($form_display && !$form_display->getComponent('field_flag')) {
$form_display->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 8])->save();
}
$view_display = EntityViewDisplay::load('node.ligne_comptable.default');
if ($view_display && !$view_display->getComponent('field_flag')) {
$view_display->setComponent('field_flag', ['type' => 'entity_reference_label', 'weight' => 8])->save();
}
}
@@ -25,7 +25,26 @@ dashboard:
- core/drupal
- figli_compta_ledger/vue
dashboard_compte:
js:
js/dashboard-compte.js: {}
css:
theme:
css/dashboard.css: {}
dependencies:
- core/drupal
- figli_compta_ledger/vue
admin_chrome:
css:
theme:
css/admin-chrome.css: {}
js:
js/admin-chrome.js: {}
dependencies:
- core/drupal
hide_admin_chrome:
css:
theme:
css/hide-admin-chrome.css: {}
@@ -14,10 +14,18 @@ figli_compta_ledger.dashboard:
parent: system.admin
weight: -10
figli_compta_ledger.dashboard_compte:
title: 'Tableau de bord par compte'
description: 'Entrées et versements par compte associé, reste à verser'
route_name: figli_compta_ledger.dashboard_compte
menu_name: admin
parent: system.admin
weight: -9
figli_compta_ledger.history:
title: 'Historique du grand livre'
description: 'Toutes les révisions de toutes les lignes comptables'
route_name: figli_compta_ledger.history
menu_name: admin
parent: system.admin
weight: -9
weight: -8
@@ -14,6 +14,7 @@ use Drupal\Core\Entity\EntityStorageException;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\CloseModalDialogCommand;
use Drupal\Core\Ajax\MessageCommand;
use Drupal\Core\Ajax\ReplaceCommand;
/**
@@ -52,8 +53,10 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state,
}
// field_entree_liee only makes sense on the "sortie" types that pay out
// against a client invoice -- charge/autre/ouverture aren't tied to a
// specific entrée, and entree lines don't link to themselves.
// against a client invoice -- charge/autre/ouverture/salaire_stage
// aren't tied to a specific entrée, and entree lines don't link to
// themselves. Mirrors LedgerActionsController::LINKABLE_TYPES (the
// inline type-change endpoint) and js/home.js's LINKABLE_TYPES.
if (isset($form['field_entree_liee'])) {
$form['field_entree_liee']['#states'] = [
'visible' => [
@@ -62,6 +65,8 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state,
[':input[name="field_type_ligne"]' => ['value' => 'achat']],
'or',
[':input[name="field_type_ligne"]' => ['value' => 'hebergement']],
'or',
[':input[name="field_type_ligne"]' => ['value' => 'sous_traitant']],
],
];
}
@@ -117,10 +122,32 @@ function figli_compta_ledger_validate_repartition(array &$form, FormStateInterfa
function figli_compta_ledger_node_form_ajax_submit(array $form, FormStateInterface $form_state) {
$response = new AjaxResponse();
if ($form_state->getErrors()) {
// The re-rendered form below does mark every répartition row with an
// `error`/`has-error` CSS class (a subtle red outline), but
// setErrorByName()'s message text itself has nowhere to render --
// that normally goes through the page's status-messages region,
// which isn't part of this standalone AJAX-replaced form fragment.
// Without this, the message was silently discarded: the modal just
// stayed open with reddened fields and no visible explanation.
foreach ($form_state->getErrors() as $error) {
$response->addCommand(new MessageCommand($error, NULL, ['type' => 'error'], FALSE));
}
unset($form['#prefix'], $form['#suffix']);
$response->addCommand(new ReplaceCommand('#' . $form['#id'], $form));
return $response;
}
// Surface whatever messenger() queued during save() -- e.g. NodeForm's
// own "Ligne comptable X has been created/updated." -- before closing
// the modal. Without this the message is silently lost: the /lignes
// table only ever refreshes via AJAX afterwards ("dialog:afterclose" ->
// reloadWindow()), never a full page load, so a session-queued message
// would otherwise sit unseen until some unrelated future page render.
foreach (\Drupal::messenger()->all() as $type => $messages) {
foreach ($messages as $message) {
$response->addCommand(new MessageCommand($message, NULL, ['type' => $type], FALSE));
}
}
\Drupal::messenger()->deleteAll();
$response->addCommand(new CloseModalDialogCommand());
return $response;
}
@@ -189,25 +216,41 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
function figli_compta_ledger_theme($existing, $type, $theme, $path) {
return [
'figli_compta_home' => [
'variables' => ['can_view_history' => FALSE],
'variables' => ['can_view_history' => FALSE, 'current_route' => NULL],
'template' => 'figli-compta-home',
],
'figli_compta_dashboard' => [
'variables' => [],
'variables' => ['current_route' => NULL],
'template' => 'figli-compta-dashboard',
],
'figli_compta_dashboard_compte' => [
'variables' => ['current_route' => NULL],
'template' => 'figli-compta-dashboard-compte',
],
];
}
/**
* Implements hook_page_attachments().
*
* Core Navigation's top bar renders empty (whitespace-only regions defeat
* its own :not(:empty) visibility check) on every page, not just admin
* routes -- attach the fix globally rather than per-route.
* The admin top bar and sidebar (Gin/core Navigation chrome) are only
* hidden on this module's own front-end pages -- real Drupal admin pages
* (/admin/*, node edit forms, etc.) keep them, since Gin puts the page's
* local actions (Save included) inside the top bar there. Route-scoped
* rather than folded into the always-on admin_chrome attachment below.
*/
function figli_compta_ledger_page_attachments(array &$attachments) {
$attachments['#attached']['library'][] = 'figli_compta_ledger/admin_chrome';
$front_end_routes = [
'figli_compta_ledger.home',
'figli_compta_ledger.dashboard',
'figli_compta_ledger.history',
'figli_compta_ledger.link_entree',
];
if (in_array(\Drupal::routeMatch()->getRouteName(), $front_end_routes, TRUE)) {
$attachments['#attached']['library'][] = 'figli_compta_ledger/hide_admin_chrome';
}
}
/**
@@ -14,6 +14,14 @@ figli_compta_ledger.dashboard:
requirements:
_permission: 'access content'
figli_compta_ledger.dashboard_compte:
path: '/dashboard/compte'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::compteView'
_title: 'Tableau de bord par compte - SAS Figures Libres'
requirements:
_permission: 'access content'
figli_compta_ledger.history:
path: '/lignes/historique'
defaults:
@@ -33,3 +41,66 @@ figli_compta_ledger.link_entree:
parameters:
node:
type: entity:node
figli_compta_ledger.api_totaux_annee:
path: '/lignes/api/totaux'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::totauxAnnee'
requirements:
_permission: 'access content'
figli_compta_ledger.api_annees:
path: '/lignes/api/annees'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::annees'
requirements:
_permission: 'access content'
figli_compta_ledger.api_reconciliation_ouverture:
path: '/lignes/api/reconciliation-ouverture'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::reconciliationOuverture'
requirements:
_permission: 'access content'
figli_compta_ledger.api_groupe_entree:
path: '/lignes/api/groupe/{node}'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::groupeEntree'
requirements:
_permission: 'access content'
options:
parameters:
node:
type: entity:node
figli_compta_ledger.api_dashboard_stats:
path: '/dashboard/api/stats'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardStatsController::stats'
requirements:
_permission: 'access content'
figli_compta_ledger.update_type:
path: '/lignes/{node}/type'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerActionsController::updateType'
methods: [POST]
requirements:
_entity_access: 'node.update'
options:
parameters:
node:
type: entity:node
figli_compta_ledger.update_field:
path: '/lignes/{node}/champ'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerActionsController::updateField'
methods: [POST]
requirements:
_entity_access: 'node.update'
options:
parameters:
node:
type: entity:node
@@ -0,0 +1,64 @@
/**
* @file
* Non-error Drupal status messages (status/warning) auto-dismiss after a
* couple seconds; error messages always require an explicit click on
* Gin's own dismiss button (.js-message-button-hide) -- something that
* needs attention shouldn't vanish on its own.
*
* Drupal.Message.add() (used both for messages already in the initial
* page render and for MessageCommand-driven AJAX messages) just appends a
* DOM node directly -- it never re-fires Drupal.attachBehaviors(), so a
* behaviors/once()-based approach would miss anything added after the
* initial page load. A MutationObserver sees every insertion path.
*/
(function (Drupal) {
'use strict';
var AUTO_DISMISS_MS = 2000;
function dismiss(el) {
var btn = el.querySelector('.js-message-button-hide');
if (btn) {
btn.click();
} else {
el.style.opacity = 0;
el.classList.add('visually-hidden');
}
}
function handleMessage(el) {
if (el.dataset.figliAutoDismissSeen) return;
el.dataset.figliAutoDismissSeen = '1';
if (el.classList.contains('messages--error')) return;
setTimeout(function () {
dismiss(el);
}, AUTO_DISMISS_MS);
}
function scan(root) {
root.querySelectorAll('.messages-list__item').forEach(handleMessage);
}
function init() {
scan(document);
new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
mutation.addedNodes.forEach(function (node) {
if (node.nodeType !== 1) return;
if (node.classList && node.classList.contains('messages-list__item')) {
handleMessage(node);
}
if (node.querySelectorAll) {
scan(node);
}
});
});
}).observe(document.body, { childList: true, subtree: true });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})(Drupal);
@@ -0,0 +1,546 @@
/**
* @file
* Dashboard par compte associé (freelance) : un compte à la fois, choisi
* dans un menu. L'objectif principal est de faire ressortir clairement les
* "versement freelance" pas (encore) compensés par une "entrée client" --
* les autres types de ligne (charge, achat, hébergement, sous-traitant...)
* ne rentrent pas dans ce rapprochement, seuls entrée/versement comptent
* ici (contrairement à /lignes, où tout type "liable" est concerné).
*
* Comme dashboard.js : pas de librairie de graphes, tout est fait en
* div/CSS (voir dashboard.css) -- un dépendance de plus pour une poignée de
* barres n'en vaut pas la peine.
*
* Le rapprochement entrée/versement reprend exactement l'algorithme déjà
* en place dans home.js (reconciliationByEntree) : un versement peut être
* lié à plusieurs entrées à la fois (paiement groupé), auquel cas son
* montant est réparti à parts égales entre elles. Dupliqué ici plutôt que
* factorisé -- home.js et dashboard.js sont déjà deux fichiers autonomes
* sans module partagé, donc c'est la convention existante du projet, pas
* une entorse.
*/
(function (Drupal, Vue) {
'use strict';
const API_BASE = '/jsonapi/node/ligne_comptable';
const EUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' });
const EUR_ROUND = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 });
const MONTHS_SHORT = ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.'];
// Same labels/colors as dashboard.js's "Répartition de l'activité par
// type" -- duplicated rather than shared, see this file's docblock.
const TYPE_LABELS = {
entree: 'Entrée client',
charge: 'Charge structurelle',
versement: 'Versement freelance',
achat: 'Achat client',
hebergement: 'Hébergement',
sous_traitant: 'Sous-traitant',
salaire_stage: 'Salaire / stage',
charges_local_pro: 'Charges local pro',
autre: 'Autre',
};
const TYPE_COLORS = {
entree: '#1a7f37',
charge: '#6b7280',
versement: '#d97a0a',
achat: '#3b6fe0',
hebergement: '#0e9182',
sous_traitant: '#c9312b',
salaire_stage: '#0891b2',
charges_local_pro: '#65a30d',
autre: '#9061f0',
};
function resolve(includedMap, ref) {
if (!ref) return null;
return includedMap.get(ref.type + ':' + ref.id) || null;
}
// Same shape as home.js's buildRows() -- only the two types this page
// cares about ever reach it (see fetchEntreesEtVersements()).
function buildRows(data, includedMap) {
const rows = [];
for (const node of data) {
const rels = node.relationships || {};
const attrs = node.attributes;
const clientTerm = resolve(includedMap, rels.field_client && rels.field_client.data);
const entreeLieeRefs = (rels.field_entree_liee && rels.field_entree_liee.data) || [];
const entreeLieeIds = entreeLieeRefs.map((ref) => ref.id);
const flagRefs = (rels.field_flag && rels.field_flag.data) || [];
const flags = flagRefs.map((ref) => resolve(includedMap, ref)).filter(Boolean).map((t) => t.attributes.name);
const parCompte = {};
const repartitionRefs = (rels.field_repartition && rels.field_repartition.data) || [];
for (const ref of repartitionRefs) {
const paragraph = resolve(includedMap, ref);
if (!paragraph) continue;
const montant = parseFloat(paragraph.attributes.field_montant || 0);
const compteTerm = resolve(includedMap, paragraph.relationships && paragraph.relationships.field_compte && paragraph.relationships.field_compte.data);
const compteName = compteTerm ? compteTerm.attributes.name : '(compte inconnu)';
parCompte[compteName] = (parCompte[compteName] || 0) + montant;
}
rows.push({
id: node.id,
nid: attrs.drupal_internal__nid,
date: attrs.field_date_ligne,
type: attrs.field_type_ligne,
client: clientTerm ? clientTerm.attributes.name : null,
libelle: attrs.field_notes || attrs.title,
parCompte,
entreeLieeIds,
flags,
hasFlag: flags.length > 0,
});
}
rows.sort((a, b) => (a.date || '').localeCompare(b.date || ''));
return rows;
}
// Every entrée/versement, whatever their date -- unlike /lignes there's
// no sliding window here: this page needs the *complete* picture to
// reconcile a compte's entrées against its versements (one can easily
// be paid out a year or more after the other), and entrée+versement
// alone is a small enough slice of the ~1500+ line ledger to fetch in
// one page load (the rest -- charge/achat/hébergement/sous-traitant/
// autre/ouverture -- is exactly what this page deliberately excludes).
async function fetchEntreesEtVersements() {
const params = new URLSearchParams();
params.set('include', 'field_repartition,field_repartition.field_compte,field_client,field_entree_liee,field_flag');
params.set('filter[typeFilter][condition][path]', 'field_type_ligne');
params.set('filter[typeFilter][condition][operator]', 'IN');
params.append('filter[typeFilter][condition][value][]', 'entree');
params.append('filter[typeFilter][condition][value][]', 'versement');
params.set('page[limit]', '50');
params.set('sort', 'field_date_ligne,drupal_internal__nid');
let url = API_BASE + '?' + params.toString();
const allData = [];
const includedMap = new Map();
while (url) {
const res = await fetch(url, { headers: { Accept: 'application/vnd.api+json' } });
if (!res.ok) throw new Error('JSON:API a répondu ' + res.status);
const json = await res.json();
allData.push(...(json.data || []));
(json.included || []).forEach((item) => includedMap.set(item.type + ':' + item.id, item));
url = json.links && json.links.next ? json.links.next.href : null;
}
const seen = new Set();
const dedup = allData.filter((n) => (seen.has(n.id) ? false : (seen.add(n.id), true)));
return buildRows(dedup, includedMap);
}
// page[limit]=200 would be silently clamped to core's hard cap of 50 --
// same reasoning as fetchClientNames() in home.js -- but with only 8
// comptes, one page always covers all of them; the pagination loop is
// kept anyway so this doesn't silently break if the vocabulary grows.
async function fetchComptes() {
let url = '/jsonapi/taxonomy_term/compte?sort=weight,name&page[limit]=50';
const names = [];
while (url) {
const res = await fetch(url, { headers: { Accept: 'application/vnd.api+json' } });
if (!res.ok) throw new Error('JSON:API a répondu ' + res.status);
const json = await res.json();
names.push(...(json.data || []).map((t) => t.attributes.name).filter(Boolean));
url = json.links && json.links.next ? json.links.next.href : null;
}
return names;
}
// Reuses the existing whole-ledger aggregate endpoint (plain SQL, all
// comptes/types/années at once) for the numbers that must reflect the
// *true* accounting balance -- solde par compte -- rather than
// recomputing a partial one from just entrée+versement rows, which
// would silently ignore that compte's charges/achats/etc.
async function fetchDashboardStats() {
const res = await fetch('/dashboard/api/stats', { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error('/dashboard/api/stats a répondu ' + res.status);
return res.json();
}
function readHashCompte() {
const params = new URLSearchParams(location.hash.replace(/^#/, ''));
return params.get('compte') || '';
}
function writeHashCompte(compte) {
const params = new URLSearchParams();
if (compte) params.set('compte', compte);
const hash = params.toString();
history.replaceState(null, '', location.pathname + location.search + (hash ? '#' + hash : ''));
}
// Vertical bar(s) per year, diverging from a zero baseline -- shared by
// "Évolution du solde" (one bar/year) and "Entrées vs versements par
// année" (two bars/year, side by side). A single flexible component
// instead of two near-identical ones.
const YearBarsChart = {
props: {
// [{ label, bars: [{ value, color, title }] }]
years: { type: Array, required: true },
formatValue: { type: Function, required: true },
},
computed: {
maxAbs() {
return Math.max(1, ...this.years.flatMap((y) => y.bars.map((b) => Math.abs(b.value))));
},
},
methods: {
barStyle(bar) {
const pct = (Math.abs(bar.value) / this.maxAbs) * 100;
return bar.value >= 0
? { bottom: '50%', height: pct / 2 + '%', background: bar.color }
: { top: '50%', height: pct / 2 + '%', background: bar.color };
},
},
template:
'<div class="figli-year-chart">' +
'<div class="figli-year-col" v-for="y in years" :key="y.label">' +
'<div class="figli-year-track">' +
'<div class="figli-year-bars">' +
'<div class="figli-year-bar-slot" v-for="(bar, i) in y.bars" :key="i">' +
'<div class="figli-year-bar" :style="barStyle(bar)" :title="bar.title + \' : \' + formatValue(bar.value)"></div>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="figli-year-label">{{ y.label }}</div>' +
'</div>' +
'</div>',
};
// Same shape as dashboard.js's HBarChart, colorFor included (used by
// the type-breakdown chart; Top clients below just omits it and gets
// the plain positive-green default).
const HBarChart = {
props: {
items: { type: Array, required: true },
formatValue: { type: Function, required: true },
colorFor: { type: Function, default: null },
// Narrower label/value columns, smaller text -- for the per-année
// small-multiples grids, where a full-width chart wouldn't fit in
// a grid card.
compact: { type: Boolean, default: false },
},
computed: {
maxAbs() {
return Math.max(1, ...this.items.map((i) => Math.abs(i.value)));
},
},
methods: {
fillColor(item) {
return this.colorFor ? this.colorFor(item) : 'var(--figli-positive)';
},
},
template:
'<div class="figli-hbar-chart" :class="{\'is-compact\': compact}">' +
'<div class="figli-hbar-row" v-for="item in items" :key="item.label">' +
'<div class="figli-hbar-label" :title="item.label">{{ item.label }}</div>' +
'<div class="figli-hbar-track">' +
'<div class="figli-hbar-fill" :style="{left: 0, width: (Math.abs(item.value) / maxAbs * 100) + \'%\', background: fillColor(item)}"></div>' +
'</div>' +
'<div class="figli-hbar-value">{{ formatValue(item.value) }}</div>' +
'</div>' +
'</div>',
};
const App = {
components: { YearBarsChart, HBarChart },
data() {
return {
loading: true,
error: null,
allRows: [],
comptes: [],
stats: null,
selectedCompte: '',
// Filtre "Lignes signalées" -- même modèle multi-valeurs/OR que
// sur /lignes. Propre à ce compte (voir selectCompte()).
flagFilter: [],
};
},
computed: {
sortiesByEntree() {
const map = new Map();
for (const r of this.allRows) {
for (const entreeId of r.entreeLieeIds) {
if (!map.has(entreeId)) map.set(entreeId, []);
map.get(entreeId).push(r);
}
}
return map;
},
// Same algorithm as home.js's reconciliationByEntree: per entrée,
// per compte, résidu = part de l'entrée pour ce compte + part des
// versements liés pour ce compte (montants négatifs), un versement
// lié à plusieurs entrées voyant son montant réparti à parts égales
// entre elles.
reconciliationByEntree() {
const map = new Map();
for (const entreeRow of this.allRows) {
if (entreeRow.type !== 'entree') continue;
const linked = this.sortiesByEntree.get(entreeRow.id) || [];
const versementsParCompte = {};
for (const s of linked) {
const share = s.entreeLieeIds.length || 1;
for (const [compte, montant] of Object.entries(s.parCompte)) {
versementsParCompte[compte] = (versementsParCompte[compte] || 0) + montant / share;
}
}
const comptes = new Set([...Object.keys(entreeRow.parCompte), ...Object.keys(versementsParCompte)]);
const parCompteResidual = {};
for (const c of comptes) {
parCompteResidual[c] = Math.round(((entreeRow.parCompte[c] || 0) + (versementsParCompte[c] || 0)) * 100) / 100;
}
map.set(entreeRow.id, { parCompteResidual });
}
return map;
},
entreesDuCompte() {
if (!this.selectedCompte) return [];
return this.allRows.filter((r) => r.type === 'entree' && r.parCompte[this.selectedCompte] !== undefined);
},
// Entrées dont la part du compte sélectionné n'est pas (entièrement)
// versée -- résidu positif = encore dû, négatif = sur-versé. C'est
// le coeur de la page : ce que le collectif doit encore à ce
// compte associé, entrée par entrée.
resteAVerserRows() {
return this.entreesDuCompte
.map((entree) => {
const recon = this.reconciliationByEntree.get(entree.id);
const residual = recon ? (recon.parCompteResidual[this.selectedCompte] ?? entree.parCompte[this.selectedCompte]) : entree.parCompte[this.selectedCompte];
return {
entree,
montantAttribue: Math.round(entree.parCompte[this.selectedCompte] * 100) / 100,
residual,
dejaVerse: Math.round((entree.parCompte[this.selectedCompte] - residual) * 100) / 100,
};
})
.filter((r) => Math.abs(r.residual) > 0.01)
.sort((a, b) => (a.entree.date || '').localeCompare(b.entree.date || ''));
},
resteAVerserPositif() {
return this.resteAVerserRows.filter((r) => r.residual > 0.01);
},
surVerseRows() {
return this.resteAVerserRows.filter((r) => r.residual < -0.01);
},
// Versements de ce compte qui ne pointent vers aucune entrée du
// tout -- ni "reste à verser" ni "sur-versé" ne les couvre (ces
// deux listes ne regardent que les *entrées*), donc sans ça un
// versement orphelin resterait invisible alors que c'est justement
// le genre d'anomalie que cette page doit faire ressortir.
versementsNonLies() {
if (!this.selectedCompte) return [];
return this.allRows
.filter((r) => r.type === 'versement' && r.parCompte[this.selectedCompte] !== undefined && r.entreeLieeIds.length === 0)
.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
},
// Toute ligne entrée/versement de ce compte portant un signalement
// (voir /lignes), qu'elle apparaisse déjà dans une des listes
// ci-dessus ou non -- une ligne entièrement soldée peut quand même
// porter un problème sans rapport avec le montant (ex. "client
// injoignable"), auquel cas aucune des trois listes ci-dessus ne
// la montrerait autrement.
lignesSignalees() {
if (!this.selectedCompte) return [];
return this.allRows
.filter((r) => r.hasFlag && r.parCompte[this.selectedCompte] !== undefined)
.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
},
// Tags réellement présents parmi les lignes signalées de ce compte
// -- dérivé de lignesSignalees (pas de lignesSignaleesFiltrees), pour
// que la liste d'options du filtre reste stable même une fois un tag
// sélectionné (sinon les autres tags disparaîtraient du menu dès
// qu'on en coche un).
flagsDisponibles() {
return Array.from(new Set(this.lignesSignalees.flatMap((r) => r.flags))).sort();
},
// Même modèle "plusieurs valeurs, sémantique OR" que le filtre Type
// sur /lignes -- une ligne ressort si elle porte au moins un des
// tags cochés.
lignesSignaleesFiltrees() {
if (!this.flagFilter.length) return this.lignesSignalees;
return this.lignesSignalees.filter((r) => this.flagFilter.some((f) => r.flags.includes(f)));
},
// Solde net des lignes signalées (filtrées) elles-mêmes (entrées
// reçues moins versements sortis, pour ce compte) -- pas un total
// "reste dû" comme totalResteAVerser ci-dessous, juste la somme des
// montants affichés dans le tableau, pour avoir une idée de
// l'ampleur de ce qui est signalé (ou de ce sous-ensemble de tags).
totalLignesSignalees() {
return Math.round(this.lignesSignaleesFiltrees.reduce((sum, r) => sum + r.parCompte[this.selectedCompte], 0) * 100) / 100;
},
totalResteAVerser() {
return Math.round(this.resteAVerserPositif.reduce((sum, r) => sum + r.residual, 0) * 100) / 100;
},
totalSurVerse() {
return Math.round(this.surVerseRows.reduce((sum, r) => sum - r.residual, 0) * 100) / 100;
},
totalNonLies() {
return Math.round(this.versementsNonLies.reduce((sum, r) => sum - (r.parCompte[this.selectedCompte] || 0), 0) * 100) / 100;
},
soldeActuel() {
if (!this.stats || !this.selectedCompte) return 0;
return this.stats.solde_par_compte[this.selectedCompte] || 0;
},
// Une seule barre/année, verte au-dessus de zéro / rouge en
// dessous -- solde de clôture de ce compte, année par année (même
// source que /dashboard, filtrée à ce seul compte).
evolutionSoldeYears() {
if (!this.stats || !this.selectedCompte) return [];
return this.stats.annees.map((y) => {
const v = this.stats.solde_par_compte_par_annee[y] ? this.stats.solde_par_compte_par_annee[y][this.selectedCompte] : undefined;
const value = v !== undefined ? v : 0;
return {
label: y,
bars: [{ value, color: value >= 0 ? 'var(--figli-positive)' : 'var(--figli-error)', title: 'Solde ' + y }],
};
});
},
// Deux barres/année : entrées attribuées à ce compte (vert, vers le
// haut) et versements de ce compte (rouge, déjà négatifs -- vers le
// bas) -- répond visuellement, année par année, à la question
// centrale de cette page.
entreeVsVersementYears() {
if (!this.stats || !this.selectedCompte) return [];
const parAnnee = new Map();
for (const y of this.stats.annees) parAnnee.set(y, { entree: 0, versement: 0 });
for (const r of this.allRows) {
const montant = r.parCompte[this.selectedCompte];
if (montant === undefined || !r.date) continue;
const y = r.date.slice(0, 4);
if (!parAnnee.has(y)) continue;
const bucket = parAnnee.get(y);
if (r.type === 'entree') bucket.entree += montant;
else bucket.versement += montant;
}
return this.stats.annees.map((y) => {
const b = parAnnee.get(y);
return {
label: y,
bars: [
{ value: Math.round(b.entree * 100) / 100, color: 'var(--figli-positive)', title: 'Entrées ' + y },
{ value: Math.round(b.versement * 100) / 100, color: 'var(--figli-error)', title: 'Versements ' + y },
],
};
});
},
topClientsItems() {
const parClient = new Map();
for (const r of this.entreesDuCompte) {
const client = r.client || '(sans client)';
parClient.set(client, (parClient.get(client) || 0) + r.parCompte[this.selectedCompte]);
}
return Array.from(parClient.entries())
.map(([label, value]) => ({ label, value: Math.round(value * 100) / 100 }))
.sort((a, b) => b.value - a.value)
.slice(0, 10);
},
// Same chart as /dashboard's "Répartition de l'activité par type",
// filtered to this compte -- unlike the reconciliation tables above
// (deliberately entrée/versement only, see this file's docblock),
// this comes straight from /dashboard/api/stats's per-compte
// breakdown, so it covers every type touching this compte's
// répartition (charge, achat, hébergement...), matching what the
// general dashboard shows for the whole ledger.
typeItems() {
if (!this.stats || !this.selectedCompte) return [];
const parType = this.stats.total_par_type_par_compte[this.selectedCompte] || {};
return Object.entries(parType)
.map(([type, value]) => ({ label: TYPE_LABELS[type] || type, value, type }))
.sort((a, b) => b.value - a.value);
},
// Small multiples, one per year -- same source as typeItems() above
// (total_par_type_par_compte_par_annee is the same répartition-level
// SQL query, just also grouped by année, no extra request).
typeItemsParAnnee() {
if (!this.stats || !this.selectedCompte) return [];
return this.stats.annees.map((annee) => {
const parType = (this.stats.total_par_type_par_compte_par_annee[annee] || {})[this.selectedCompte] || {};
const items = Object.entries(parType)
.map(([type, value]) => ({ label: TYPE_LABELS[type] || type, value, type }))
.sort((a, b) => b.value - a.value);
return { annee, items };
}).filter((y) => y.items.length);
},
// Top 5 (not 10 like the all-time chart, above) -- one per year
// keeps the small-multiples grid readable.
topClientsParAnnee() {
if (!this.selectedCompte) return [];
return this.stats.annees.map((annee) => {
const parClient = new Map();
for (const r of this.entreesDuCompte) {
if ((r.date || '').slice(0, 4) !== annee) continue;
const client = r.client || '(sans client)';
parClient.set(client, (parClient.get(client) || 0) + r.parCompte[this.selectedCompte]);
}
const items = Array.from(parClient.entries())
.map(([label, value]) => ({ label, value: Math.round(value * 100) / 100 }))
.sort((a, b) => b.value - a.value)
.slice(0, 5);
return { annee, items };
}).filter((y) => y.items.length);
},
},
methods: {
formatEur(v) {
return v === null || v === undefined ? '' : EUR.format(v);
},
formatEurRound(v) {
return EUR_ROUND.format(v);
},
typeColor(item) {
return TYPE_COLORS[item.type] || '#6b7280';
},
formatDate(d) {
if (!d) return '';
const parts = d.split('-');
return parts[2] + ' ' + MONTHS_SHORT[parseInt(parts[1], 10) - 1] + ' ' + parts[0];
},
// Ouvre le grand livre déjà filtré sur ce client -- pour aller voir
// le détail des lignes plutôt que de dupliquer une vue détaillée
// ici.
ligneHref(client) {
return '/lignes#client=' + encodeURIComponent(client) + '&type=entree,versement';
},
selectCompte(compte) {
this.selectedCompte = compte;
// A tag selected for one compte may not even exist for the next
// one -- flagsDisponibles() would just drop it from the visible
// options while leaving it silently active in flagFilter.
this.flagFilter = [];
writeHashCompte(compte);
},
async load() {
this.loading = true;
this.error = null;
try {
const [allRows, comptes, stats] = await Promise.all([
fetchEntreesEtVersements(),
fetchComptes(),
fetchDashboardStats(),
]);
this.allRows = allRows;
this.comptes = comptes;
this.stats = stats;
const fromHash = readHashCompte();
this.selectedCompte = fromHash && comptes.includes(fromHash) ? fromHash : (comptes[0] || '');
} catch (err) {
this.error = err.message;
} finally {
this.loading = false;
}
},
},
mounted() {
this.load();
},
};
Drupal.behaviors.figliComptaDashboardCompte = {
attach(context) {
const root = context.querySelector ? context.querySelector('#figli-dashboard-app') : null;
if (root && !root.dataset.figliInitialized) {
root.dataset.figliInitialized = '1';
Vue.createApp(App).mount(root);
}
},
};
})(Drupal, Vue);
@@ -1,107 +1,225 @@
/**
* @file
* Aggregate dashboard: solde par compte / solde par client, computed
* client-side from JSON:API. The line-by-line spreadsheet view is the
* site's home page (home.js), not this one.
* Dashboard: charts and aggregate totals (solde par compte, chiffre
* d'affaires par année, répartition par type, top clients), computed
* server-side (DashboardStatsController -- plain SQL GROUP BY, not Entity
* API) and rendered here as small dependency-free div/CSS bar charts. No
* charting library: this project vendors its own JS (see js/vendor/), and
* a handful of bar/line charts don't warrant pulling one in.
*/
(function (Drupal, Vue) {
'use strict';
const API_BASE = '/jsonapi/node/ligne_comptable';
const EUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' });
const EUR_ROUND = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 });
const TYPE_LABELS = {
entree: 'Entrée client',
charge: 'Charge structurelle',
versement: 'Versement freelance',
achat: 'Achat client',
hebergement: 'Hébergement',
sous_traitant: 'Sous-traitant',
salaire_stage: 'Salaire / stage',
charges_local_pro: 'Charges local pro',
autre: 'Autre',
};
// Stable colour per type, independent of sort order -- a viewer
// comparing this chart across page loads shouldn't see "achat" change
// colour just because its rank shifted.
const TYPE_COLORS = {
entree: '#1a7f37',
charge: '#6b7280',
versement: '#d97a0a',
achat: '#3b6fe0',
hebergement: '#0e9182',
sous_traitant: '#c9312b',
salaire_stage: '#0891b2',
charges_local_pro: '#65a30d',
autre: '#9061f0',
};
async function fetchAllLignes() {
// sort by nid: without an explicit, unique sort key, offset pagination
// can silently duplicate or skip rows across pages.
let url = API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client&page[limit]=50&sort=drupal_internal__nid';
const allData = [];
const includedMap = new Map();
while (url) {
const res = await fetch(url, { headers: { Accept: 'application/vnd.api+json' } });
if (!res.ok) throw new Error('JSON:API a répondu ' + res.status);
const json = await res.json();
allData.push(...(json.data || []));
(json.included || []).forEach((item) => includedMap.set(item.type + ':' + item.id, item));
url = json.links && json.links.next ? json.links.next.href : null;
}
// Defensive de-dup by node id, in case pagination ever repeats a row.
const seen = new Set();
const dedup = allData.filter((n) => (seen.has(n.id) ? false : (seen.add(n.id), true)));
return { data: dedup, includedMap };
async function fetchStats() {
const res = await fetch('/dashboard/api/stats', { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error('/dashboard/api/stats a répondu ' + res.status);
return res.json();
}
function resolve(includedMap, ref) {
if (!ref) return null;
return includedMap.get(ref.type + ':' + ref.id) || null;
}
// Horizontal bar chart -- one row per item, label left, proportional
// bar, value right. Switches to a zero-centered "diverging" layout
// automatically when values can be negative (solde par compte), so a
// debit and a credit of the same magnitude read as mirror images
// instead of one dwarfing the other from a shared zero baseline.
const HBarChart = {
props: {
items: { type: Array, required: true },
formatValue: { type: Function, required: true },
colorFor: { type: Function, default: null },
},
computed: {
hasNegative() {
return this.items.some((i) => i.value < 0);
},
maxAbs() {
return Math.max(1, ...this.items.map((i) => Math.abs(i.value)));
},
},
methods: {
fillStyle(item) {
const pct = (Math.abs(item.value) / this.maxAbs) * 100;
if (this.hasNegative) {
return item.value >= 0
? { left: '50%', width: pct / 2 + '%' }
: { right: '50%', width: pct / 2 + '%' };
}
return { left: 0, width: pct + '%' };
},
fillColor(item) {
if (this.colorFor) return this.colorFor(item);
return item.value < 0 ? 'var(--figli-error)' : 'var(--figli-positive)';
},
},
template:
'<div class="figli-hbar-chart">' +
'<div class="figli-hbar-row" v-for="item in items" :key="item.label">' +
'<div class="figli-hbar-label" :title="item.label">{{ item.label }}</div>' +
'<div class="figli-hbar-track" :class="{\'is-diverging\': hasNegative}">' +
'<div class="figli-hbar-zero" v-if="hasNegative"></div>' +
'<div class="figli-hbar-fill" :style="[fillStyle(item), {background: fillColor(item)}]"></div>' +
'</div>' +
'<div class="figli-hbar-value">{{ formatValue(item.value) }}</div>' +
'</div>' +
'</div>',
};
function addTo(map, key, montant) {
if (!map.has(key)) map.set(key, { entrees: 0, sorties: 0 });
const row = map.get(key);
if (montant >= 0) row.entrees += montant;
else row.sorties += montant;
}
// Vertical bar chart -- for a short time series (CA par année): a
// handful of columns read left-to-right as a trend more naturally than
// horizontal bars would.
const VBarChart = {
props: {
items: { type: Array, required: true },
formatValue: { type: Function, required: true },
},
computed: {
max() {
return Math.max(1, ...this.items.map((i) => i.value));
},
},
methods: {
barHeight(item) {
return Math.max(2, (item.value / this.max) * 100) + '%';
},
},
template:
'<div class="figli-vbar-chart">' +
'<div class="figli-vbar-col" v-for="item in items" :key="item.label">' +
'<div class="figli-vbar-value">{{ formatValue(item.value) }}</div>' +
'<div class="figli-vbar-track"><div class="figli-vbar-fill" :style="{height: barHeight(item)}"></div></div>' +
'<div class="figli-vbar-label">{{ item.label }}</div>' +
'</div>' +
'</div>',
};
function computeAggregations(data, includedMap) {
const parComptes = new Map();
const parClients = new Map();
for (const node of data) {
const rels = node.relationships || {};
const clientTerm = resolve(includedMap, rels.field_client && rels.field_client.data);
const clientName = clientTerm ? clientTerm.attributes.name : '(sans client)';
const repartitionRefs = (rels.field_repartition && rels.field_repartition.data) || [];
for (const ref of repartitionRefs) {
const paragraph = resolve(includedMap, ref);
if (!paragraph) continue;
const montant = parseFloat(paragraph.attributes.field_montant || 0);
const compteTerm = resolve(includedMap, paragraph.relationships && paragraph.relationships.field_compte && paragraph.relationships.field_compte.data);
const compteName = compteTerm ? compteTerm.attributes.name : '(compte inconnu)';
addTo(parComptes, compteName, montant);
addTo(parClients, clientName, montant);
}
}
return { parComptes, parClients };
}
function mapToRows(map) {
return Array.from(map.entries())
.map(([name, v]) => ({ name, entrees: v.entrees, sorties: v.sorties, solde: v.entrees + v.sorties }))
.sort((a, b) => a.solde - b.solde);
}
function totalsOf(rows) {
return rows.reduce(
(acc, r) => ({ entrees: acc.entrees + r.entrees, sorties: acc.sorties + r.sorties, solde: acc.solde + r.solde }),
{ entrees: 0, sorties: 0, solde: 0 }
);
}
// Small multiples: one compact zero-centered bar-per-year trend per
// compte, instead of a single 8-series line chart -- eight overlapping
// lines sharing one small area is hard to read; eight small independent
// trends, each answering "is this person's balance growing or
// shrinking", is not.
const MiniTrend = {
props: {
annees: { type: Array, required: true },
values: { type: Array, required: true },
formatValue: { type: Function, required: true },
},
computed: {
maxAbs() {
return Math.max(1, ...this.values.filter((v) => v !== null).map((v) => Math.abs(v)));
},
},
methods: {
barHeight(v) {
if (v === null) return '0%';
return Math.max(3, (Math.abs(v) / this.maxAbs) * 100) + '%';
},
},
template:
'<div class="figli-mini-trend">' +
'<div class="figli-mini-bar-col" v-for="(v, i) in values" :key="annees[i]" :title="annees[i] + \' : \' + (v === null ? \'—\' : formatValue(v))">' +
'<div class="figli-mini-bar-track">' +
'<div class="figli-mini-bar-fill" :class="v !== null && v < 0 ? \'is-negative\' : \'is-positive\'" :style="{height: barHeight(v)}"></div>' +
'</div>' +
'<div class="figli-mini-bar-label">{{ annees[i].slice(2) }}</div>' +
'</div>' +
'</div>',
};
const App = {
components: { HBarChart, ColumnChart: VBarChart, MiniTrend },
data() {
return { loading: true, error: null, tables: [], lineCount: 0 };
return { loading: true, error: null, stats: null };
},
computed: {
caParAnneeItems() {
if (!this.stats) return [];
return this.stats.annees.map((y) => ({ label: y, value: this.stats.ca_par_annee[y] || 0 }));
},
soldeParCompteItems() {
if (!this.stats) return [];
return Object.entries(this.stats.solde_par_compte)
.map(([label, value]) => ({ label, value }))
.sort((a, b) => b.value - a.value);
},
typeItems() {
if (!this.stats) return [];
return Object.entries(this.stats.total_par_type)
.map(([type, value]) => ({ label: TYPE_LABELS[type] || type, value, type }))
.sort((a, b) => b.value - a.value);
},
topClientsItems() {
if (!this.stats) return [];
return this.stats.top_clients.map((c) => ({ label: c.client, value: c.ca }));
},
// Comptes ordered by all-time solde (richest first) -- same order
// as soldeParCompteItems, so the trend grid below reads as a
// continuation of the bar chart above it rather than an unrelated
// shuffle.
comptesOrdonnes() {
return this.soldeParCompteItems.map((i) => i.label);
},
totalCA() {
if (!this.stats) return 0;
return Object.values(this.stats.ca_par_annee).reduce((a, b) => a + b, 0);
},
caAnneeEnCours() {
if (!this.stats || !this.stats.annees.length) return null;
const derniere = this.stats.annees[this.stats.annees.length - 1];
return { annee: derniere, value: this.stats.ca_par_annee[derniere] || 0 };
},
totalActivite() {
if (!this.stats) return 0;
return Object.values(this.stats.total_par_type).reduce((a, b) => a + b, 0);
},
},
methods: {
formatEur(v) {
return EUR.format(v);
},
rowClass(solde) {
if (solde > 0.5) return 'positive';
if (solde < -0.5) return 'negative';
return '';
formatEurRound(v) {
return EUR_ROUND.format(v);
},
trendValues(compte) {
return this.stats.annees.map((y) => {
const parAnnee = this.stats.solde_par_compte_par_annee[y];
return parAnnee && parAnnee[compte] !== undefined ? parAnnee[compte] : null;
});
},
typeColor(item) {
return TYPE_COLORS[item.type] || '#6b7280';
},
async load() {
this.loading = true;
this.error = null;
try {
const { data, includedMap } = await fetchAllLignes();
this.lineCount = data.length;
const { parComptes, parClients } = computeAggregations(data, includedMap);
const comptesRows = mapToRows(parComptes);
const clientsRows = mapToRows(parClients);
this.tables = [
{ title: 'Solde par compte', note: this.lineCount + ' lignes comptables chargées.', rows: comptesRows, totals: totalsOf(comptesRows) },
{ title: 'Solde par client', note: 'Entrées créditées par client vs. montants sortis (versements, achats, charges) sur les lignes rattachées à ce client.', rows: clientsRows, totals: totalsOf(clientsRows) },
];
this.stats = await fetchStats();
} catch (err) {
this.error = err.message;
} finally {
File diff suppressed because it is too large Load Diff
@@ -18,6 +18,7 @@ class DashboardController extends ControllerBase {
return [
'#theme' => 'figli_compta_home',
'#can_view_history' => $this->currentUser()->hasPermission('view ligne_comptable revisions'),
'#current_route' => 'figli_compta_ledger.home',
'#attached' => [
'library' => ['figli_compta_ledger/home'],
],
@@ -25,15 +26,33 @@ class DashboardController extends ControllerBase {
}
/**
* Secondary page: aggregate solde par compte / par client.
* Secondary page: charts and aggregate totals (par compte, par année,
* par client, par type).
*/
public function view() {
return [
'#theme' => 'figli_compta_dashboard',
'#current_route' => 'figli_compta_ledger.dashboard',
'#attached' => [
'library' => ['figli_compta_ledger/dashboard'],
],
];
}
/**
* Third page: one compte associé (freelance) at a time -- entrées client
* vs versements freelance, and above all which entrées haven't been
* (fully) paid out yet. Complements the aggregate /dashboard above,
* which mixes every compte and every type together.
*/
public function compteView() {
return [
'#theme' => 'figli_compta_dashboard_compte',
'#current_route' => 'figli_compta_ledger.dashboard_compte',
'#attached' => [
'library' => ['figli_compta_ledger/dashboard_compte'],
],
];
}
}
@@ -0,0 +1,195 @@
<?php
namespace Drupal\figli_compta_ledger\Controller;
use Drupal\Core\Controller\ControllerBase;
use Symfony\Component\HttpFoundation\JsonResponse;
/**
* Single aggregate endpoint backing the charts on /dashboard. Plain SQL
* (Database API), not Entity API -- with 1500+ ligne_comptable nodes,
* loading full entities the way the old dashboard.js did (fetch every node
* via JSON:API, aggregate client-side) is the exact performance problem
* the /lignes sliding window was built to avoid; a handful of GROUP BY
* queries answers every chart in one page load instead.
*/
class DashboardStatsController extends ControllerBase {
/**
* Same reasoning as home.js's MIN_LOADABLE_DATE/MAX_LOADABLE_DATE:
* comfortably before the earliest migrated year (2021) and well past
* any plausible future-dated entry, wide enough to never need updating.
*/
const MIN_ANNEE = '2020';
const MAX_ANNEE = '2030';
/**
* Whether a year is real enough to appear in a per-year chart -- see
* MIN_ANNEE/MAX_ANNEE. Doesn't affect all-time totals, which count
* every line regardless of its date.
*/
private function isAnneeValide(string $annee): bool {
return $annee >= self::MIN_ANNEE && $annee < self::MAX_ANNEE;
}
/**
* GET /dashboard/api/stats.
*/
public function stats() {
$connection = \Drupal::database();
// Node-level aggregate: one row per ligne_comptable (annee, type,
// client, montant_ht) -- backs chiffre d'affaires, per-type, and
// per-client breakdowns. Joining field_repartition here would
// multiply each node by its répartition row count and inflate
// montant_ht sums, so it's deliberately kept separate from the
// répartition-level query below.
$nodeQuery = $connection->select('node__field_date_ligne', 'd');
$nodeQuery->innerJoin('node__field_type_ligne', 't', 't.entity_id = d.entity_id');
$nodeQuery->leftJoin('node__field_client', 'ncl', 'ncl.entity_id = d.entity_id');
$nodeQuery->leftJoin('taxonomy_term_field_data', 'cl', 'cl.tid = ncl.field_client_target_id');
$nodeQuery->leftJoin('node__field_montant_ht', 'mh', 'mh.entity_id = d.entity_id');
$nodeQuery->condition('d.bundle', 'ligne_comptable');
$nodeQuery->addExpression('SUBSTRING(d.field_date_ligne_value, 1, 4)', 'annee');
$nodeQuery->addField('t', 'field_type_ligne_value', 'type');
$nodeQuery->addField('cl', 'name', 'client');
$nodeQuery->addField('mh', 'field_montant_ht_value', 'montant_ht');
$nodeRows = $nodeQuery->execute()->fetchAll();
// Répartition-level aggregate: one row per (node, compte) répartition
// share, pre-summed per (annee, compte, type) in SQL -- backs solde
// par compte, both all-time and per-year (each year's own total
// already includes that year's ouverture line, so it *is* that
// year's closing balance -- same logic as
// LedgerStatsController::totauxAnnee()), and the per-compte type
// breakdown used by /dashboard/compte.
$compteQuery = $connection->select('node__field_date_ligne', 'd');
$compteQuery->innerJoin('node__field_type_ligne', 't2', 't2.entity_id = d.entity_id');
$compteQuery->innerJoin('node__field_repartition', 'r', 'r.entity_id = d.entity_id');
$compteQuery->innerJoin('paragraph__field_montant', 'm', 'm.entity_id = r.field_repartition_target_id');
$compteQuery->innerJoin('paragraph__field_compte', 'c', 'c.entity_id = r.field_repartition_target_id');
$compteQuery->innerJoin('taxonomy_term_field_data', 'tc', 'tc.tid = c.field_compte_target_id');
$compteQuery->condition('d.bundle', 'ligne_comptable');
$compteQuery->addExpression('SUBSTRING(d.field_date_ligne_value, 1, 4)', 'annee');
$compteQuery->addField('tc', 'name', 'compte');
$compteQuery->addField('t2', 'field_type_ligne_value', 'type');
$compteQuery->addExpression('SUM(m.field_montant_value)', 'total');
$compteQuery->groupBy('annee');
$compteQuery->groupBy('compte');
$compteQuery->groupBy('type');
$compteRows = $compteQuery->execute()->fetchAll();
// --- Aggregate the node-level rows in PHP. ---
$caParAnnee = [];
$totalParType = [];
$caParClient = [];
$annees = [];
foreach ($nodeRows as $row) {
$montant = $row->montant_ht !== NULL ? (float) $row->montant_ht : 0.0;
// All-time totals (type breakdown, client ranking) count every line
// regardless of date -- a mistyped date doesn't make the money any
// less real. Only the per-year buckets below need a sane year.
if ($row->type !== 'ouverture') {
$totalParType[$row->type] = ($totalParType[$row->type] ?? 0) + abs($montant);
}
if ($row->type === 'entree') {
$client = $row->client ?: '(sans client)';
$caParClient[$client] = ($caParClient[$client] ?? 0) + $montant;
}
if (!$this->isAnneeValide($row->annee)) {
continue;
}
$annees[$row->annee] = TRUE;
if ($row->type === 'entree') {
$caParAnnee[$row->annee] = ($caParAnnee[$row->annee] ?? 0) + $montant;
}
}
arsort($caParClient);
$topClients = [];
$i = 0;
foreach ($caParClient as $client => $total) {
if ($i++ >= 12) {
break;
}
$topClients[] = ['client' => $client, 'ca' => round($total, 2)];
}
// --- Aggregate the répartition-level rows in PHP. ---
$soldeParCompte = [];
$soldeParCompteParAnnee = [];
$totalParTypeParCompte = [];
$totalParTypeParCompteParAnnee = [];
foreach ($compteRows as $row) {
$total = (float) $row->total;
// Same reasoning: the all-time balance includes every line; the
// per-year trend only makes sense for a real year.
$soldeParCompte[$row->compte] = ($soldeParCompte[$row->compte] ?? 0) + $total;
// Per-compte équivalent of $totalParType above -- every type
// counts here (not just entree/versement), same "hors ouverture,
// valeur absolue" convention.
if ($row->type !== 'ouverture') {
$totalParTypeParCompte[$row->compte][$row->type] =
($totalParTypeParCompte[$row->compte][$row->type] ?? 0) + abs($total);
}
if (!$this->isAnneeValide($row->annee)) {
continue;
}
$annees[$row->annee] = TRUE;
// compteRows now has one row per (année, compte, type) -- several
// types can share the same (année, compte), so this has to
// accumulate, not overwrite, or only the last type processed for
// that year+compte would survive.
$soldeParCompteParAnnee[$row->annee][$row->compte] =
round(($soldeParCompteParAnnee[$row->annee][$row->compte] ?? 0) + $total, 2);
// Per-année version of $totalParTypeParCompte above, for the
// "par année" small multiples on /dashboard/compte -- same rows,
// no extra query.
if ($row->type !== 'ouverture') {
$totalParTypeParCompteParAnnee[$row->annee][$row->compte][$row->type] =
($totalParTypeParCompteParAnnee[$row->annee][$row->compte][$row->type] ?? 0) + abs($total);
}
}
// array_keys() alone would leak PHP's array-key int-casting here: a
// key that looks like a canonical integer ("2021") is silently stored
// as an int, not a string, and json_encode() then emits it as a bare
// JSON number in this *list* -- unlike object keys (ca_par_annee,
// solde_par_compte_par_annee below), which JSON always stringifies
// regardless of the PHP source type. The frontend expects every year
// as a string throughout, so cast explicitly.
$anneesList = array_map('strval', array_keys($annees));
sort($anneesList);
// Chronological order per year, not insertion order -- PHP's array
// key order for soldeParCompteParAnnee follows first-seen compte per
// year, which can differ year to year.
ksort($soldeParCompteParAnnee);
ksort($totalParTypeParCompteParAnnee);
return new JsonResponse([
'annees' => $anneesList,
'ca_par_annee' => array_map(fn ($v) => round($v, 2), $caParAnnee),
'total_par_type' => array_map(fn ($v) => round($v, 2), $totalParType),
'top_clients' => $topClients,
'solde_par_compte' => array_map(fn ($v) => round($v, 2), $soldeParCompte),
'solde_par_compte_par_annee' => $soldeParCompteParAnnee,
'total_par_type_par_compte' => array_map(
fn ($parType) => array_map(fn ($v) => round($v, 2), $parType),
$totalParTypeParCompte
),
'total_par_type_par_compte_par_annee' => array_map(
fn ($parCompte) => array_map(
fn ($parType) => array_map(fn ($v) => round($v, 2), $parType),
$parCompte
),
$totalParTypeParCompteParAnnee
),
]);
}
}
@@ -0,0 +1,248 @@
<?php
namespace Drupal\figli_compta_ledger\Controller;
use Drupal\Core\Access\CsrfRequestHeaderAccessCheck;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\EntityStorageException;
use Drupal\node\NodeInterface;
use Drupal\taxonomy\Entity\Term;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
/**
* Small write endpoints backing inline (no-modal) edits from the /lignes
* table. Each goes through the normal node save() lifecycle -- same as the
* full edit form -- so figli_compta_ledger_node_presave() still forces a
* proper revision. The répartition invariant check is deliberately
* skipped for these saves though (same as the type-change endpoint
* below): none of client/facture/libellé/type touch montant_ht or
* field_repartition, so skipping can never *introduce* a mismatch, only
* leave a pre-existing historical one exactly as it was -- see each
* method's own comment.
*/
class LedgerActionsController extends ControllerBase {
/**
* Fields editable inline from /lignes without opening the full node
* edit form -- keys are the short names the frontend sends; values are
* the real field machine names.
*/
const INLINE_EDITABLE_FIELDS = [
'client' => 'field_client',
'facture' => 'field_numero_facture',
'libelle' => 'field_notes',
'flag' => 'field_flag',
];
/**
* Every value field_type_ligne actually allows (see the field's
* allowed_values in config) -- validated against here rather than
* trusting the client.
*/
const ALLOWED_TYPES = ['entree', 'charge', 'versement', 'achat', 'hebergement', 'autre', 'ouverture', 'sous_traitant', 'salaire_stage', 'charges_local_pro'];
/**
* Types field_entree_liee is meaningful for -- mirrors the #states
* visibility rule in figli_compta_ledger_form_alter(). Sous-traitant
* pays out against a client's work just like versement/achat; a
* salaire/stage or charges local pro is a structural cost like charge,
* not tied to one client's invoice.
*/
const LINKABLE_TYPES = ['versement', 'achat', 'hebergement', 'sous_traitant'];
/**
* POST /lignes/{node}/type -- change field_type_ligne without opening
* the full edit form, for clicking the type badge directly in the
* table. Body: {"type": "charge"}.
*/
public function updateType(Request $request, NodeInterface $node) {
if ($node->bundle() !== 'ligne_comptable') {
return new JsonResponse(['error' => 'Type de contenu invalide.'], 404);
}
// Scoped to CsrfRequestHeaderAccessCheck::TOKEN_KEY -- the same value
// core's own /session/token controller generates against, which is
// what the frontend fetches this token from.
$csrfToken = $request->headers->get('X-CSRF-Token', '');
if (!\Drupal::csrfToken()->validate($csrfToken, CsrfRequestHeaderAccessCheck::TOKEN_KEY)) {
return new JsonResponse(['error' => 'Jeton de sécurité invalide, rechargez la page.'], 403);
}
$data = json_decode($request->getContent(), TRUE);
$type = is_array($data) ? ($data['type'] ?? NULL) : NULL;
if (!in_array($type, self::ALLOWED_TYPES, TRUE)) {
return new JsonResponse(['error' => 'Type de ligne invalide.'], 400);
}
if ($conflict = $this->checkConflict($request, $node)) {
return $conflict;
}
$node->set('field_type_ligne', $type);
// A type that's no longer linkable shouldn't keep a stale
// field_entree_liee reference around (mirrors the form's #states:
// charge/autre/ouverture/entree don't expose that field at all).
if (!in_array($type, self::LINKABLE_TYPES, TRUE)
&& $node->hasField('field_entree_liee')
&& !$node->get('field_entree_liee')->isEmpty()) {
$node->set('field_entree_liee', NULL);
}
// Only field_type_ligne (and possibly field_entree_liee) changes here
// -- montant_ht and field_repartition are untouched, so this can never
// *introduce* a répartition mismatch, only leave a pre-existing one
// (from historical data, never corrected -- see figli_compta_ledger's
// module docblock) exactly as it was. The présave check exists to
// catch new inconsistent entries, not to block relabeling the type of
// an already-migrated line, so skip it for this save only. try/finally
// guarantees the global flag clears even if save() throws for an
// unrelated reason -- leaving it on would silently skip validation on
// every other save on the site.
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
try {
$node->save();
}
catch (EntityStorageException $e) {
return new JsonResponse(['error' => $e->getMessage()], 422);
}
finally {
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
}
return new JsonResponse([
'success' => TRUE,
'type' => $type,
'entree_liee_cleared' => !in_array($type, self::LINKABLE_TYPES, TRUE),
'changed' => date(DATE_ATOM, $node->getChangedTime()),
]);
}
/**
* POST /lignes/{node}/champ -- change client/facture/libellé/signalement
* inline, for clicking directly on those cells in the table. Body:
* {"field": "client", "value": "EPAU / POPSU"}. An empty value clears
* the field (e.g. a structural charge with no client).
*/
public function updateField(Request $request, NodeInterface $node) {
if ($node->bundle() !== 'ligne_comptable') {
return new JsonResponse(['error' => 'Type de contenu invalide.'], 404);
}
$csrfToken = $request->headers->get('X-CSRF-Token', '');
if (!\Drupal::csrfToken()->validate($csrfToken, CsrfRequestHeaderAccessCheck::TOKEN_KEY)) {
return new JsonResponse(['error' => 'Jeton de sécurité invalide, rechargez la page.'], 403);
}
$data = json_decode($request->getContent(), TRUE);
$field = is_array($data) ? ($data['field'] ?? NULL) : NULL;
$value = trim((string) (is_array($data) ? ($data['value'] ?? '') : ''));
if (!isset(self::INLINE_EDITABLE_FIELDS[$field])) {
return new JsonResponse(['error' => 'Champ invalide.'], 400);
}
$fieldName = self::INLINE_EDITABLE_FIELDS[$field];
if ($conflict = $this->checkConflict($request, $node)) {
return $conflict;
}
if ($field === 'client') {
$node->set('field_client', $value === '' ? NULL : $this->findOrCreateTerm('client', $value)->id());
}
elseif ($field === 'flag') {
// Comma-separated like a native "tags" widget -- one or more
// free-form tags, each matched against an existing term or
// auto-created (same reasoning as client above). Order/dedup
// doesn't matter here, this is a display list, not a répartition.
$names = array_unique(array_filter(array_map('trim', explode(',', $value)), fn ($n) => $n !== ''));
$tids = array_map(fn ($name) => $this->findOrCreateTerm('flag', $name)->id(), $names);
$node->set('field_flag', $tids);
}
else {
$node->set($fieldName, $value !== '' ? $value : NULL);
}
// Same reasoning as updateType() above: only client/facture/libellé/
// signalement changes here, montant_ht and field_repartition are
// untouched, so skipping the répartition check for this save can
// never introduce a mismatch -- it can only leave a pre-existing
// historical one exactly as it was.
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
try {
$node->save();
}
catch (EntityStorageException $e) {
return new JsonResponse(['error' => $e->getMessage()], 422);
}
finally {
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
}
if ($field === 'client') {
$newValue = $node->get('field_client')->entity ? $node->get('field_client')->entity->label() : NULL;
}
elseif ($field === 'flag') {
$newValue = array_map(fn ($item) => $item->entity ? $item->entity->label() : NULL, iterator_to_array($node->get('field_flag')));
$newValue = array_values(array_filter($newValue));
}
else {
$newValue = $node->get($fieldName)->value;
}
return new JsonResponse([
'success' => TRUE,
'field' => $field,
'value' => $newValue,
'changed' => date(DATE_ATOM, $node->getChangedTime()),
]);
}
/**
* Finds an existing term by name in $vid, or creates one -- shared by
* the client and flag (signalement) inline-edit cases above. Same
* "autocreate" behavior as a standard Drupal entity reference
* autocomplete/tags widget: the front-end's datalist only *suggests*
* known names, it doesn't restrict input to them.
*/
private function findOrCreateTerm(string $vid, string $name): Term {
$terms = $this->entityTypeManager()->getStorage('taxonomy_term')
->loadByProperties(['vid' => $vid, 'name' => $name]);
if ($terms) {
return reset($terms);
}
$term = Term::create(['vid' => $vid, 'name' => $name]);
$term->save();
return $term;
}
/**
* Optimistic-locking guard shared by both endpoints above: the
* frontend sends the `changed` timestamp of the row it last saw (see
* buildRows() in home.js), captured at load/last-successful-save
* time. If the node's *actual* changed time has since moved on --
* someone else saved this same line in between -- the two won't
* match, and we reject rather than silently overwrite whatever that
* other save touched. Absent entirely (older cached frontend, or a
* request that genuinely doesn't know it, e.g. a fresh row from
* autocreate) skips the check rather than blocking on a false
* mismatch -- the check is a safety net for the common case, not a
* hard requirement of the API contract.
*/
private function checkConflict(Request $request, NodeInterface $node) {
$data = json_decode($request->getContent(), TRUE);
$clientChanged = is_array($data) ? ($data['changed'] ?? NULL) : NULL;
if ($clientChanged === NULL) {
return NULL;
}
$clientChangedTs = strtotime($clientChanged);
if ($clientChangedTs !== FALSE && $clientChangedTs !== (int) $node->getChangedTime()) {
return new JsonResponse([
'error' => 'Cette ligne a été modifiée par quelqu\'un d\'autre entre-temps. Rechargez la page pour voir les dernières modifications.',
'conflict' => TRUE,
], 409);
}
return NULL;
}
}
@@ -0,0 +1,227 @@
<?php
namespace Drupal\figli_compta_ledger\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\node\NodeInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
/**
* Small aggregate endpoints backing the /lignes sliding window: the
* row-level JSON:API fetch only ever covers a date range (see home.js), so
* neither the totals footer nor the "Année" dropdown can be computed from
* whatever's currently loaded -- they need their own always-accurate
* queries, decoupled from the row window.
*/
class LedgerStatsController extends ControllerBase {
/**
* GET /lignes/api/totaux?annee=2023 -- per-compte répartition sums (plus
* montant HT/TTC/écart totals) for every ligne_comptable dated that
* year, including ouverture lines: the footer is meant to read as the
* actual account balance (solde) for the year, i.e. the same "clôture
* calculée" (ouverture + every movement dated within the year) that
* reconciliationOuverture() compares the *next* year's ouverture
* against. Excluding ouverture here would make this a net-movement
* figure instead, which never matches what the reconciliation badge's
* tooltip cites for the same year.
*/
public function totauxAnnee(Request $request) {
$annee = $request->query->get('annee');
if (!$annee || !preg_match('/^\d{4}$/', $annee)) {
return new JsonResponse(['error' => 'Paramètre "annee" invalide.'], 400);
}
$storage = $this->entityTypeManager()->getStorage('node');
$nids = $storage->getQuery()
->accessCheck(TRUE)
->condition('type', 'ligne_comptable')
->condition('field_date_ligne', $annee . '-01-01', '>=')
->condition('field_date_ligne', ((int) $annee + 1) . '-01-01', '<')
->execute();
$par_compte = [];
$montant_ht = 0.0;
$montant_ttc = 0.0;
$ecart = 0.0;
foreach ($storage->loadMultiple($nids) as $node) {
$ht = $node->hasField('field_montant_ht') && !$node->get('field_montant_ht')->isEmpty()
? (float) $node->get('field_montant_ht')->value : 0.0;
$ttc = $node->hasField('field_montant_ttc') && !$node->get('field_montant_ttc')->isEmpty()
? (float) $node->get('field_montant_ttc')->value : 0.0;
$montant_ht += $ht;
$montant_ttc += $ttc;
$somme = 0.0;
foreach ($node->get('field_repartition')->referencedEntities() as $paragraph) {
if (!$paragraph->hasField('field_montant') || $paragraph->get('field_montant')->isEmpty()) {
continue;
}
$montant = (float) $paragraph->get('field_montant')->value;
$compte = $paragraph->get('field_compte')->entity ? $paragraph->get('field_compte')->entity->label() : NULL;
if ($compte) {
$par_compte[$compte] = ($par_compte[$compte] ?? 0) + $montant;
}
$somme += $montant;
}
$ecart += round($ht - $somme, 2);
}
return new JsonResponse([
'annee' => $annee,
'montant_ht' => round($montant_ht, 2),
'montant_ttc' => round($montant_ttc, 2),
'ecart' => round($ecart, 2),
'par_compte' => array_map(fn ($v) => round($v, 2), $par_compte),
]);
}
/**
* GET /lignes/api/annees -- distinct years, most recent first, with at
* least 5 lines. The threshold exists specifically to keep the handful
* of mistyped historical dates (preserved as-is -- e.g. a "0213" typo
* for "2023") from polluting the year filter with bogus one-line
* "years". A plain SQL aggregate, not Entity API: this only needs the
* date column, not full node loads.
*/
public function annees() {
$connection = \Drupal::database();
$query = $connection->select('node__field_date_ligne', 'd');
$query->addExpression('SUBSTRING(d.field_date_ligne_value, 1, 4)', 'annee');
$query->addExpression('COUNT(*)', 'total');
$query->condition('d.bundle', 'ligne_comptable');
$query->groupBy('annee');
$query->having('COUNT(*) >= 5');
$results = $query->execute()->fetchCol();
$annees = array_values($results);
rsort($annees);
return new JsonResponse(['annees' => $annees]);
}
/**
* GET /lignes/api/reconciliation-ouverture -- per compte, checks that
* each year's "ouverture" (opening balance) matches the *calculated*
* closing balance of the previous year (that year's own ouverture plus
* every movement dated within it). The historical spreadsheets carried
* real year-to-year gaps that were preserved as-is during migration
* (never corrected) -- this surfaces them instead of hiding them, same
* principle as the per-ligne écart column.
*
* Response: { "2023": { "Bachir": -12.34, "EXT.WEB": 5 }, ... } -- only
* years with at least one compte off by more than a cent, and never the
* very first year on record (nothing to compare it against).
*/
public function reconciliationOuverture() {
$connection = \Drupal::database();
$query = $connection->select('node__field_date_ligne', 'd');
$query->innerJoin('node__field_type_ligne', 't', 't.entity_id = d.entity_id');
$query->innerJoin('node__field_repartition', 'r', 'r.entity_id = d.entity_id');
$query->innerJoin('paragraph__field_montant', 'm', 'm.entity_id = r.field_repartition_target_id');
$query->innerJoin('paragraph__field_compte', 'c', 'c.entity_id = r.field_repartition_target_id');
$query->innerJoin('taxonomy_term_field_data', 'tc', 'tc.tid = c.field_compte_target_id');
$query->condition('d.bundle', 'ligne_comptable');
$query->addExpression('SUBSTRING(d.field_date_ligne_value, 1, 4)', 'annee');
$query->addExpression("CASE WHEN t.field_type_ligne_value = 'ouverture' THEN 1 ELSE 0 END", 'is_ouverture');
$query->addField('tc', 'name', 'compte');
$query->addExpression('SUM(m.field_montant_value)', 'total');
$query->groupBy('annee');
$query->groupBy('is_ouverture');
$query->groupBy('compte');
$rows = $query->execute()->fetchAll();
$ouverture = [];
$mouvement = [];
foreach ($rows as $row) {
if ($row->is_ouverture) {
$ouverture[$row->annee][$row->compte] = ($ouverture[$row->annee][$row->compte] ?? 0) + (float) $row->total;
}
else {
$mouvement[$row->annee][$row->compte] = ($mouvement[$row->annee][$row->compte] ?? 0) + (float) $row->total;
}
}
$annees = array_unique(array_merge(array_keys($ouverture), array_keys($mouvement)));
sort($annees);
$result = [];
foreach ($annees as $i => $annee) {
if ($i === 0) {
continue;
}
$precedente = (string) ((int) $annee - 1);
if (empty($ouverture[$annee]) || (!isset($ouverture[$precedente]) && !isset($mouvement[$precedente]))) {
continue;
}
$comptes = array_unique(array_merge(
array_keys($ouverture[$annee]),
array_keys($ouverture[$precedente] ?? []),
array_keys($mouvement[$precedente] ?? [])
));
$ecarts = [];
foreach ($comptes as $compte) {
$ouvertureReelle = $ouverture[$annee][$compte] ?? 0;
$clotureCalculee = ($ouverture[$precedente][$compte] ?? 0) + ($mouvement[$precedente][$compte] ?? 0);
$ecart = round($ouvertureReelle - $clotureCalculee, 2);
if (abs($ecart) > 0.01) {
$ecarts[$compte] = $ecart;
}
}
if ($ecarts) {
$result[$annee] = $ecarts;
}
}
return new JsonResponse($result);
}
/**
* GET /lignes/api/groupe/{node} -- the full transitive closure of
* entrée/sortie nodes connected via field_entree_liee, following links
* in *both* directions (this node's own field_entree_liee targets, and
* any other node that references it). The client's sliding window only
* ever holds a date-range slice of `rows` -- a versement can link to
* (or be linked from) an entrée dated years earlier or later, so
* finding the complete group from whatever happens to be loaded is not
* possible client-side; the reverse direction in particular ("which
* sorties reference this entrée") needs a real query, not a scan of
* already-loaded rows. Returns node ids only (not full ligne data) --
* the client re-fetches those specific ids via JSON:API, reusing its
* existing row-building/reconciliation code for the result.
*/
public function groupeEntree(NodeInterface $node) {
$storage = $this->entityTypeManager()->getStorage('node');
$ids = [(int) $node->id() => TRUE];
$queue = [(int) $node->id()];
while ($queue) {
$current_id = array_shift($queue);
$current = $storage->load($current_id);
if (!$current || !$current->hasField('field_entree_liee')) {
continue;
}
foreach ($current->get('field_entree_liee')->referencedEntities() as $entree) {
$eid = (int) $entree->id();
if (!isset($ids[$eid])) {
$ids[$eid] = TRUE;
$queue[] = $eid;
}
}
$referencing = $storage->getQuery()
->accessCheck(TRUE)
->condition('type', 'ligne_comptable')
->condition('field_entree_liee', $current_id)
->execute();
foreach ($referencing as $nid) {
$nid = (int) $nid;
if (!isset($ids[$nid])) {
$ids[$nid] = TRUE;
$queue[] = $nid;
}
}
}
return new JsonResponse(['nids' => array_keys($ids)]);
}
}
@@ -13,7 +13,17 @@ use Drupal\node\NodeInterface;
* Quick-link form: sets field_entree_liee on a single sortie line without
* opening the full ligne_comptable edit form -- the associates only ever
* need to touch this one field to link a versement/achat/hébergement to
* the entrée client it pays out against.
* the entrée client(s) it pays out against. field_entree_liee is
* multi-value (cardinality unlimited) since one payment sometimes covers
* several client invoices at once; one autocomplete field per linked
* entrée plus an "Ajouter une référence" button, mirroring the classic
* Drupal multi-value widget (e.g. the ligne_comptable form's own
* Répartition paragraphs) rather than a single comma-separated field.
* Clearing a field's text (rather than a dedicated "remove" button)
* drops that link -- see submitForm() -- since a "remove" button would
* need to shift every later delta, which fights Drupal's own
* value-restoration-on-AJAX-rebuild (that only works cleanly for pure
* appends at a stable delta, which is all addItem() below ever does).
*/
class LinkEntreeForm extends FormBase {
@@ -30,19 +40,50 @@ class LinkEntreeForm extends FormBase {
public function buildForm(array $form, FormStateInterface $form_state, ?NodeInterface $node = NULL) {
$form_state->set('node', $node);
$form['field_entree_liee'] = [
'#type' => 'entity_autocomplete',
'#title' => $this->t('Entrée client liée'),
'#target_type' => 'node',
'#selection_handler' => 'figli_compta_ledger:entree_client',
'#selection_settings' => [
'target_bundles' => ['ligne_comptable' => 'ligne_comptable'],
// Narrows the autocomplete to the same client as this sortie --
// read by EntreeClientSelection::buildEntityQuery().
'entity' => $node,
],
'#default_value' => $node->get('field_entree_liee')->entity,
'#description' => $this->t('Laisser vide pour retirer le lien.'),
// Number of entrée-liée fields to render -- seeded from the node's
// current values on first build, then only grown by addItem() across
// AJAX rebuilds. At least one field even when nothing is linked yet.
if ($form_state->get('items_count') === NULL) {
$existing_count = $node->get('field_entree_liee')->count();
$form_state->set('items_count', max($existing_count, 1));
}
$existing = $node->get('field_entree_liee')->referencedEntities();
$form['#tree'] = TRUE;
$form['items'] = [
'#type' => 'container',
'#attributes' => ['id' => 'figli-entree-liee-items'],
];
for ($delta = 0; $delta < $form_state->get('items_count'); $delta++) {
$form['items'][$delta] = [
'target' => [
'#type' => 'entity_autocomplete',
'#title' => $this->t('Entrée cliente liée'),
'#title_display' => 'invisible',
'#target_type' => 'node',
'#selection_handler' => 'figli_compta_ledger:entree_client',
'#selection_settings' => [
'target_bundles' => ['ligne_comptable' => 'ligne_comptable'],
// Narrows the autocomplete to the same client as this sortie
// -- read by EntreeClientSelection::buildEntityQuery().
'entity' => $node,
],
'#default_value' => $existing[$delta] ?? NULL,
],
];
}
$form['add'] = [
'#type' => 'submit',
'#value' => $this->t('+ Ajouter une référence'),
'#submit' => ['::addItem'],
'#ajax' => ['callback' => '::ajaxRefreshItems', 'wrapper' => 'figli-entree-liee-items'],
'#limit_validation_errors' => [],
];
$form['description'] = [
'#weight' => -10,
'#markup' => '<p class="description">' . $this->t('Plusieurs entrées possibles (paiement en plusieurs fois) : une référence par champ, ajoutez-en avec le bouton ci-dessus. Vider un champ retire ce lien.') . '</p>',
];
$form['actions'] = ['#type' => 'actions'];
@@ -57,13 +98,35 @@ class LinkEntreeForm extends FormBase {
return $form;
}
/**
* #submit for "+ Ajouter une référence".
*/
public function addItem(array &$form, FormStateInterface $form_state) {
$form_state->set('items_count', $form_state->get('items_count') + 1);
$form_state->setRebuild();
}
/**
* #ajax callback for "Ajouter" -- replaces just the fields container,
* leaving the rest of the (still-open) modal alone.
*/
public function ajaxRefreshItems(array &$form, FormStateInterface $form_state) {
return $form['items'];
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
/** @var \Drupal\node\NodeInterface $node */
$node = $form_state->get('node');
$node->set('field_entree_liee', $form_state->getValue('field_entree_liee') ?: NULL);
$values = [];
foreach ($form_state->getValue('items') as $item) {
if (!empty($item['target'])) {
$values[] = ['target_id' => $item['target']];
}
}
$node->set('field_entree_liee', $values);
$node->save();
}
@@ -23,9 +23,16 @@ class EntreeClientSelection extends NodeSelection {
/**
* {@inheritdoc}
*
* Matches on title *or* field_numero_facture -- 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 ("F2549") is what
* actually finds the right one quickly. $match is withheld from the
* parent call (it would otherwise add its own title-only condition)
* and applied manually below as an OR across both fields instead.
*/
protected function buildEntityQuery($match = NULL, $match_operator = 'CONTAINS') {
$query = parent::buildEntityQuery($match, $match_operator);
$query = parent::buildEntityQuery(NULL, $match_operator);
$query->condition('field_type_ligne', 'entree');
$entity = $this->configuration['entity'] ?? NULL;
@@ -33,6 +40,14 @@ class EntreeClientSelection extends NodeSelection {
$query->condition('field_client', $entity->get('field_client')->target_id);
}
if (isset($match)) {
$query->condition(
$query->orConditionGroup()
->condition('title', $match, $match_operator)
->condition('field_numero_facture', $match, $match_operator)
);
}
return $query;
}
@@ -0,0 +1,192 @@
{#
Dashboard par compte associé (freelance) : un compte à la fois, choisi
dans le sélecteur. Se concentre sur entrée client / versement freelance
uniquement -- l'objectif principal est de faire ressortir les versements
pas (encore) compensés par une entrée, pas de donner une vue comptable
complète (voir /dashboard pour ça).
{% verbatim %} ci-dessous : syntaxe Vue, pas Twig -- voir
figli-compta-dashboard.html.twig pour la même remarque.
#}
<nav class="figli-page-nav">
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">Dashboard</a>
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a>
</nav>
{% verbatim %}
<div id="figli-dashboard-app">
<p v-if="loading">Chargement des données…</p>
<p v-else-if="error" class="figli-error">Erreur de chargement du tableau de bord : {{ error }}</p>
<template v-else>
<div class="figli-compte-selector">
<label for="figli-compte-select">Compte associé</label>
<select id="figli-compte-select" :value="selectedCompte" @change="selectCompte($event.target.value)">
<option v-for="c in comptes" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<div class="figli-summary-row">
<div class="figli-summary-card">
<div class="figli-summary-label">Solde actuel</div>
<div class="figli-summary-value" :class="soldeActuel < 0 ? 'is-negative' : 'is-positive'">{{ formatEurRound(soldeActuel) }}</div>
</div>
<div class="figli-summary-card" :class="{'is-alert': totalResteAVerser > 0.01}">
<div class="figli-summary-label">Reste à verser</div>
<div class="figli-summary-value" :class="totalResteAVerser > 0.01 ? 'is-negative' : ''">{{ formatEurRound(totalResteAVerser) }}</div>
</div>
<div class="figli-summary-card" v-if="totalSurVerse > 0.01">
<div class="figli-summary-label">Sur-versé</div>
<div class="figli-summary-value is-negative">{{ formatEurRound(totalSurVerse) }}</div>
</div>
<div class="figli-summary-card" :class="{'is-alert': totalNonLies > 0.01}" v-if="versementsNonLies.length">
<div class="figli-summary-label">Versements non liés</div>
<div class="figli-summary-value is-negative">{{ formatEurRound(totalNonLies) }}</div>
</div>
</div>
<section class="figli-chart-section">
<h2>Reste à verser -- entrées non (entièrement) compensées</h2>
<p class="figli-note">Pour {{ selectedCompte }} : entrées client dont la part attribuée n'a pas encore été entièrement reversée. Triées de la plus ancienne à la plus récente.</p>
<table v-if="resteAVerserPositif.length" class="figli-recon-table">
<thead>
<tr><th>Date</th><th>Client</th><th>Libellé</th><th>Attribué</th><th>Déjà versé</th><th>Reste à verser</th></tr>
</thead>
<tbody>
<tr v-for="r in resteAVerserPositif" :key="r.entree.id" :class="{'figli-flag-row': r.entree.hasFlag}">
<td>{{ formatDate(r.entree.date) }}</td>
<td><a :href="ligneHref(r.entree.client)">{{ r.entree.client || '—' }}</a></td>
<td>{{ r.entree.libelle || '—' }}<span v-for="f in r.entree.flags" :key="f" class="figli-flag-badge">{{ f }}</span></td>
<td>{{ formatEur(r.montantAttribue) }}</td>
<td>{{ formatEur(r.dejaVerse) }}</td>
<td class="is-negative">{{ formatEur(r.residual) }}</td>
</tr>
</tbody>
<tfoot>
<tr><td colspan="5">Total</td><td class="is-negative">{{ formatEurRound(totalResteAVerser) }}</td></tr>
</tfoot>
</table>
<p v-else class="figli-note">Rien en attente -- toutes les entrées de ce compte sont compensées.</p>
</section>
<section class="figli-chart-section" v-if="surVerseRows.length">
<h2>Entrées sur-versées</h2>
<p class="figli-note">Le montant versé pour ces entrées dépasse la part attribuée à {{ selectedCompte }} -- à vérifier.</p>
<table class="figli-recon-table">
<thead>
<tr><th>Date</th><th>Client</th><th>Libellé</th><th>Attribué</th><th>Versé</th><th>Sur-versé</th></tr>
</thead>
<tbody>
<tr v-for="r in surVerseRows" :key="r.entree.id" :class="{'figli-flag-row': r.entree.hasFlag}">
<td>{{ formatDate(r.entree.date) }}</td>
<td><a :href="ligneHref(r.entree.client)">{{ r.entree.client || '—' }}</a></td>
<td>{{ r.entree.libelle || '—' }}<span v-for="f in r.entree.flags" :key="f" class="figli-flag-badge">{{ f }}</span></td>
<td>{{ formatEur(r.montantAttribue) }}</td>
<td>{{ formatEur(r.dejaVerse) }}</td>
<td class="is-negative">{{ formatEur(-r.residual) }}</td>
</tr>
</tbody>
<tfoot>
<tr><td colspan="5">Total</td><td class="is-negative">{{ formatEurRound(totalSurVerse) }}</td></tr>
</tfoot>
</table>
</section>
<section class="figli-chart-section" v-if="versementsNonLies.length">
<h2>Versements sans entrée liée</h2>
<p class="figli-note">Versements de {{ selectedCompte }} qui ne pointent vers aucune entrée client -- ni "reste à verser" ni "sur-versé" ci-dessus ne les couvre.</p>
<table class="figli-recon-table">
<thead>
<tr><th>Date</th><th>Client</th><th>Libellé</th><th>Montant</th></tr>
</thead>
<tbody>
<tr v-for="r in versementsNonLies" :key="r.id" :class="{'figli-flag-row': r.hasFlag}">
<td>{{ formatDate(r.date) }}</td>
<td><a :href="ligneHref(r.client)">{{ r.client || '—' }}</a></td>
<td>{{ r.libelle || '—' }}<span v-for="f in r.flags" :key="f" class="figli-flag-badge">{{ f }}</span></td>
<td class="is-negative">{{ formatEur(r.parCompte[selectedCompte]) }}</td>
</tr>
</tbody>
<tfoot>
<tr><td colspan="3">Total</td><td class="is-negative">{{ formatEurRound(-totalNonLies) }}</td></tr>
</tfoot>
</table>
</section>
<section class="figli-chart-section" v-if="lignesSignalees.length">
<h2>Lignes signalées</h2>
<p class="figli-note">Entrées et versements de {{ selectedCompte }} portant un signalement (voir la colonne "Signalement" dans le grand livre) -- indépendant du solde, une ligne déjà réglée peut quand même être signalée pour une autre raison.</p>
<label>Signalement
<span class="figli-filter-row">
<div class="figli-multiselect">
<div class="figli-multiselect-trigger">{{ flagFilter.length ? flagFilter.join(', ') : 'Tous' }} <span class="figli-multiselect-arrow">▾</span></div>
<div class="figli-multiselect-panel">
<label v-for="f in flagsDisponibles" :key="f" class="figli-multiselect-option">
<input type="checkbox" :value="f" v-model="flagFilter" /> {{ f }}
</label>
</div>
</div>
<button v-if="flagFilter.length" type="button" class="figli-filter-clear" @click="flagFilter = []" title="Effacer ce filtre">✕</button>
</span>
</label>
<table class="figli-recon-table">
<thead>
<tr><th>Date</th><th>Type</th><th>Client</th><th>Libellé</th><th>Signalement</th><th>Montant</th></tr>
</thead>
<tbody>
<tr v-for="r in lignesSignaleesFiltrees" :key="r.id" class="figli-flag-row">
<td>{{ formatDate(r.date) }}</td>
<td>{{ r.type === 'entree' ? 'Entrée' : 'Versement' }}</td>
<td><a :href="ligneHref(r.client)">{{ r.client || '—' }}</a></td>
<td>{{ r.libelle || '—' }}</td>
<td><span v-for="f in r.flags" :key="f" class="figli-flag-badge">{{ f }}</span></td>
<td :class="r.parCompte[selectedCompte] < 0 ? 'is-negative' : ''">{{ formatEur(r.parCompte[selectedCompte]) }}</td>
</tr>
<tr v-if="!lignesSignaleesFiltrees.length"><td colspan="6" class="figli-note">Aucune ligne pour ce signalement.</td></tr>
</tbody>
<tfoot>
<tr>
<td colspan="5">Solde</td>
<td :class="totalLignesSignalees < 0 ? 'is-negative' : ''">{{ formatEur(totalLignesSignalees) }}</td>
</tr>
</tfoot>
</table>
</section>
<section class="figli-chart-section">
<h2>Entrées vs versements par année</h2>
<p class="figli-note">Part de {{ selectedCompte }} dans les entrées client (vert) et ses versements (rouge), année par année.</p>
<year-bars-chart :years="entreeVsVersementYears" :format-value="formatEurRound"></year-bars-chart>
</section>
<section class="figli-chart-section">
<h2>Évolution du solde</h2>
<p class="figli-note">Solde de clôture de {{ selectedCompte }}, année par année (ouverture comprise).</p>
<year-bars-chart :years="evolutionSoldeYears" :format-value="formatEurRound"></year-bars-chart>
</section>
<section class="figli-chart-section" v-if="typeItems.length">
<h2>Répartition de l'activité par type</h2>
<p class="figli-note">Montant total (HT, valeur absolue) par type de ligne pour {{ selectedCompte }}, hors ouvertures -- contrairement aux tableaux ci-dessus, tous les types comptent ici (pas seulement entrée/versement).</p>
<h-bar-chart :items="typeItems" :format-value="formatEurRound" :color-for="typeColor"></h-bar-chart>
<div class="figli-year-hbar-grid">
<div class="figli-year-hbar-card" v-for="y in typeItemsParAnnee" :key="y.annee">
<div class="figli-year-hbar-title">{{ y.annee }}</div>
<h-bar-chart :items="y.items" :format-value="formatEurRound" :color-for="typeColor" compact></h-bar-chart>
</div>
</div>
</section>
<section class="figli-chart-section" v-if="topClientsItems.length">
<h2>Top clients</h2>
<p class="figli-note">Clients ayant généré le plus d'entrées attribuées à {{ selectedCompte }}, toutes années confondues.</p>
<h-bar-chart :items="topClientsItems" :format-value="formatEurRound"></h-bar-chart>
<div class="figli-year-hbar-grid">
<div class="figli-year-hbar-card" v-for="y in topClientsParAnnee" :key="y.annee">
<div class="figli-year-hbar-title">{{ y.annee }}</div>
<h-bar-chart :items="y.items" :format-value="formatEurRound" compact></h-bar-chart>
</div>
</div>
</section>
</template>
</div>
{% endverbatim %}
@@ -1,36 +1,70 @@
{#
Aggregate view: solde par compte / solde par client. The spreadsheet-like
Charts and aggregate totals: solde par compte, chiffre d'affaires par
année, répartition par type, top clients. The spreadsheet-like
line-by-line view is the site's home page (figli-compta-home.html.twig).
{% verbatim %} below: this is Vue template syntax, not Twig -- both use
{{ }}, so verbatim tells Twig to leave it alone and let Vue compile it
in the browser.
#}
<nav class="figli-page-nav">
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">Dashboard</a>
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a>
</nav>
{% verbatim %}
<div id="figli-dashboard-app">
<p v-if="loading">Chargement des données…</p>
<p v-else-if="error" class="figli-error">Erreur de chargement du tableau de bord : {{ error }}</p>
<template v-else>
<section v-for="table in tables" :key="table.title">
<h2>{{ table.title }}</h2>
<p class="figli-note" v-if="table.note">{{ table.note }}</p>
<table>
<thead><tr><th></th><th>Entrées (+)</th><th>Sorties (-)</th><th>Solde</th></tr></thead>
<tbody>
<tr v-for="row in table.rows" :key="row.name" :class="rowClass(row.solde)">
<td>{{ row.name }}</td>
<td class="amount">{{ formatEur(row.entrees) }}</td>
<td class="amount">{{ formatEur(row.sorties) }}</td>
<td class="amount">{{ formatEur(row.solde) }}</td>
</tr>
<tr class="total">
<td>TOTAL</td>
<td class="amount">{{ formatEur(table.totals.entrees) }}</td>
<td class="amount">{{ formatEur(table.totals.sorties) }}</td>
<td class="amount">{{ formatEur(table.totals.solde) }}</td>
</tr>
</tbody>
</table>
<div class="figli-summary-row">
<div class="figli-summary-card">
<div class="figli-summary-label">Chiffre d'affaires total</div>
<div class="figli-summary-value">{{ formatEurRound(totalCA) }}</div>
</div>
<div class="figli-summary-card" v-if="caAnneeEnCours">
<div class="figli-summary-label">CA {{ caAnneeEnCours.annee }}</div>
<div class="figli-summary-value">{{ formatEurRound(caAnneeEnCours.value) }}</div>
</div>
<div class="figli-summary-card" v-for="item in soldeParCompteItems.slice(0, 1)" :key="'top-' + item.label">
<div class="figli-summary-label">Meilleur solde</div>
<div class="figli-summary-value" :class="item.value < 0 ? 'is-negative' : 'is-positive'">{{ item.label }} · {{ formatEurRound(item.value) }}</div>
</div>
</div>
<section class="figli-chart-section">
<h2>Chiffre d'affaires par année</h2>
<p class="figli-note">Total des entrées client (montant HT) facturées chaque année.</p>
<column-chart :items="caParAnneeItems" :format-value="formatEurRound"></column-chart>
</section>
<section class="figli-chart-section">
<h2>Solde par compte</h2>
<p class="figli-note">Solde cumulé de chaque compte depuis l'origine (ouverture comprise) -- vert = créditeur, rouge = débiteur.</p>
<h-bar-chart :items="soldeParCompteItems" :format-value="formatEurRound"></h-bar-chart>
</section>
<section class="figli-chart-section">
<h2>Évolution du solde par compte</h2>
<p class="figli-note">Solde de clôture de chaque compte, année par année.</p>
<div class="figli-trend-grid">
<div class="figli-trend-card" v-for="compte in comptesOrdonnes" :key="compte">
<div class="figli-trend-title">{{ compte }}</div>
<mini-trend :annees="stats.annees" :values="trendValues(compte)" :format-value="formatEurRound"></mini-trend>
</div>
</div>
</section>
<section class="figli-chart-section">
<h2>Répartition de l'activité par type</h2>
<p class="figli-note">Montant total (HT, valeur absolue) par type de ligne, hors ouvertures.</p>
<h-bar-chart :items="typeItems" :format-value="formatEurRound" :color-for="typeColor"></h-bar-chart>
</section>
<section class="figli-chart-section">
<h2>Top clients par chiffre d'affaires</h2>
<p class="figli-note">Les 12 clients ayant généré le plus de chiffre d'affaires, toutes années confondues.</p>
<h-bar-chart :items="topClientsItems" :format-value="formatEurRound"></h-bar-chart>
</section>
</template>
</div>
@@ -5,6 +5,11 @@
"Ajouter une ligne" opens the real Drupal node form in a modal
(core/drupal.dialog.ajax) -- no form logic duplicated in JS.
#}
<nav class="figli-page-nav">
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">Dashboard</a>
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a>
</nav>
{% verbatim %}
<div id="figli-home-app">
<div class="figli-toolbar">
@@ -16,30 +21,72 @@
{% verbatim %}
<label>Compte
<select v-model="filterCompte">
<option value="">Tous</option>
<option v-for="c in allComptes" :key="c" :value="c">{{ c }}</option>
</select>
<span class="figli-filter-row">
<div class="figli-multiselect">
<div class="figli-multiselect-trigger">{{ filterCompte.length ? filterCompte.join(', ') : 'Tous' }} <span class="figli-multiselect-arrow">▾</span></div>
<div class="figli-multiselect-panel">
<label v-for="c in allComptes" :key="c" class="figli-multiselect-option">
<input type="checkbox" :value="c" v-model="filterCompte" /> {{ c }}
</label>
</div>
</div>
<button v-if="filterCompte.length" type="button" class="figli-filter-clear" @click="filterCompte = []" title="Effacer ce filtre">✕</button>
</span>
</label>
<label>Client
<select v-model="filterClient">
<option value="">Tous</option>
<option v-for="c in allClients" :key="c" :value="c">{{ c }}</option>
</select>
<span class="figli-filter-row">
<input type="text" v-model.lazy="filterClient" list="figli-client-datalist" placeholder="Tous" autocomplete="off" class="figli-client-input" />
<datalist id="figli-client-datalist">
<option v-for="c in allClientsList" :key="c" :value="c"></option>
</datalist>
<button v-if="filterClient" type="button" class="figli-filter-clear" @click="filterClient = ''" title="Effacer ce filtre">✕</button>
</span>
</label>
<label>Type
<select v-model="filterType">
<option value="">Tous</option>
<option v-for="t in allTypes" :key="t.value" :value="t.value">{{ t.label }}</option>
</select>
<span class="figli-filter-row">
<div class="figli-multiselect">
<div class="figli-multiselect-trigger">{{ filterType.length ? filterType.map(typeLabel).join(', ') : 'Tous' }} <span class="figli-multiselect-arrow">▾</span></div>
<div class="figli-multiselect-panel">
<label v-for="t in allTypes" :key="t.value" class="figli-multiselect-option">
<input type="checkbox" :value="t.value" v-model="filterType" /> {{ t.label }}
</label>
</div>
</div>
<button v-if="filterType.length" type="button" class="figli-filter-clear" @click="filterType = []" title="Effacer ce filtre">✕</button>
</span>
</label>
<label>Signalement
<span class="figli-filter-row">
<div class="figli-multiselect">
<div class="figli-multiselect-trigger">{{ filterFlag.length ? filterFlag.join(', ') : 'Tous' }} <span class="figli-multiselect-arrow">▾</span></div>
<div class="figli-multiselect-panel">
<label v-for="f in allFlagsList" :key="f" class="figli-multiselect-option">
<input type="checkbox" :value="f" v-model="filterFlag" /> {{ f }}
</label>
<p v-if="!allFlagsList.length" class="figli-note">Aucun signalement existant.</p>
</div>
</div>
<button v-if="filterFlag.length" type="button" class="figli-filter-clear" @click="filterFlag = []" title="Effacer ce filtre">✕</button>
</span>
</label>
<label>Année
<select v-model="filterYear">
<option value="">Toutes</option>
<option v-for="y in allYears" :key="y" :value="y">{{ y }}</option>
<span class="figli-filter-row">
<select v-model="filterYear">
<option value="">Toutes</option>
<option v-for="y in allYearsList" :key="y" :value="y">{{ y }}</option>
</select>
<button v-if="filterYear" type="button" class="figli-filter-clear" @click="filterYear = ''" title="Effacer ce filtre">✕</button>
</span>
</label>
<label>Aller à
<select v-model="jumpYearValue" @change="onJumpYearChange">
<option value="">Aller à…</option>
<option v-for="y in allYearsList" :key="y" :value="y">{{ y }}</option>
</select>
</label>
@@ -55,23 +102,32 @@
<input type="checkbox" v-model="onlyErrors" /> Écarts uniquement
</label>
<button type="button" class="button figli-clear-drilldown" v-if="filterEntreeId" @click="filterEntreeId = null">✕ Entrée + sorties liées uniquement</button>
<label class="figli-checkbox">
<input type="checkbox" v-model="onlyFlagged" /> Signalées uniquement
</label>
<span class="figli-count" v-if="!loading">{{ filteredRows.length }} / {{ rows.length }} lignes{{ errorCount ? ' — ' + errorCount + ' avec écart' : '' }}</span>
<datalist id="figli-flag-datalist">
<option v-for="f in allFlagsList" :key="f" :value="f"></option>
</datalist>
<span class="figli-count" v-if="!loading">{{ filteredRows.length }} / {{ rows.length }} lignes chargées{{ errorCount ? ' — ' + errorCount + ' avec écart' : '' }}</span>
</div>
<p v-if="typeUpdateError" class="figli-error figli-inline-error">Erreur : {{ typeUpdateError }} <button type="button" class="figli-clear-drilldown" @click="typeUpdateError = null">✕</button></p>
<p v-if="loading">Chargement des données…</p>
<p v-else-if="error" class="figli-error">Erreur de chargement : {{ error }}</p>
<div v-else class="figli-table-wrap">
<div v-else ref="tableWrap" class="figli-table-wrap">
<table ref="tableEl" @mouseover="onCellHover" @mouseleave="clearColHover">
<thead>
<tr>
<th class="actions-col"></th>
<th class="actions-col"></th>
<th>Date</th>
<th>Client</th>
<th>Type</th>
<th>Facture</th>
<th>Libellé / Détail</th>
<th>Signalement</th>
<th class="amount">Montant HT</th>
<th class="amount">Montant TTC</th>
<th v-for="c in allComptes" :key="c" class="amount compte-col">{{ c }}</th>
@@ -79,26 +135,16 @@
</tr>
</thead>
<tbody>
<tr ref="topSentinel" class="figli-sentinel-row">
<td :colspan="7 + allComptes.length + 3">
<span v-if="loadingOlder">Chargement des mois précédents…</span>
</td>
</tr>
<template v-for="item in groupedRows" :key="item.key">
<tr v-if="item.isGroup" class="figli-group-row">
<td :colspan="6 + allComptes.length + 3">{{ item.label }} <span class="figli-note">({{ item.count }} lignes)</span></td>
<tr v-if="item.isGroup" class="figli-group-row" :data-year="item.year">
<td :colspan="7 + allComptes.length + 3">{{ item.label }} <span class="figli-note">({{ item.count }} lignes)</span></td>
</tr>
<tr v-else :class="{'figli-error-row': item.hasError}">
<td class="actions-col">
<button
v-if="item.linkable"
type="button"
class="figli-link-btn"
:class="{'is-linked': item.entreeLieeId}"
:title="item.entreeLieeId ? 'Lié à : ' + item.entreeLieeLabel : 'Lier à une entrée client'"
@click="openLinkForm(item.nid)"
>
<svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 12a3 3 0 0 0 4.24 0l2-2a3 3 0 0 0-4.24-4.24l-1 1" />
<path d="M12 8a3 3 0 0 0-4.24 0l-2 2a3 3 0 0 0 4.24 4.24l1-1" />
</svg>
</button>
</td>
<tr v-else :class="{'figli-flag-row': item.hasFlag}" :data-year="item.date ? item.date.slice(0, 4) : null">
<td class="actions-col">
<button type="button" class="figli-edit-btn" title="Modifier" @click="openEditForm(item.nid)">
<svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
@@ -107,17 +153,91 @@
</button>
</td>
<td>{{ formatDate(item.date) }}</td>
<td>{{ item.client || '—' }}</td>
<td><span class="figli-badge" :class="'type-' + item.type">{{ typeLabel(item.type) }}</span></td>
<td class="figli-libelle">
{{ item.libelle }}
<td>
<input
v-if="isEditingCell(item, 'client')"
v-focus
type="text"
class="figli-inline-input"
list="figli-client-datalist"
:value="item.client"
@change="saveCell(item, 'client', $event)"
@blur="editingCell = null"
@keyup.enter="$event.target.blur()"
/>
<span v-else class="figli-editable-cell" title="Cliquer pour modifier" @click="startEditCell(item, 'client')">{{ item.client || '—' }}</span>
</td>
<td>
<select
v-if="editingTypeId === item.id"
v-focus
class="figli-type-select"
:value="item.type"
@change="saveType(item, $event)"
@blur="editingTypeId = null"
>
<option v-for="t in allTypes" :key="t.value" :value="t.value">{{ t.label }}</option>
</select>
<span
v-if="item.type === 'entree' && reconciliationByEntree.get(item.id) && reconciliationByEntree.get(item.id).count > 0"
class="figli-recon-badge"
:class="{'is-anomalie': reconciliationByEntree.get(item.id).surVerse > 0, 'is-reste': reconciliationByEntree.get(item.id).resteAVerser > 0}"
:title="reconciliationByEntree.get(item.id).detail"
@click="toggleEntreeFilter(item.id)"
>{{ reconciliationByEntree.get(item.id).count }} sortie{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }} liée{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }}<template v-if="reconciliationByEntree.get(item.id).resteAVerser > 0"> · reste {{ formatEur(reconciliationByEntree.get(item.id).resteAVerser) }}</template><template v-if="reconciliationByEntree.get(item.id).surVerse > 0"> · sur-versé {{ formatEur(reconciliationByEntree.get(item.id).surVerse) }}</template></span>
v-else
class="figli-badge figli-badge-editable"
:class="'type-' + item.type"
title="Cliquer pour changer le type"
@click="startEditType(item)"
>{{ typeLabel(item.type) }}</span>
</td>
<td>
<input
v-if="isEditingCell(item, 'facture')"
v-focus
type="text"
class="figli-inline-input"
:value="item.facture"
@change="saveCell(item, 'facture', $event)"
@blur="editingCell = null"
@keyup.enter="$event.target.blur()"
/>
<span v-else class="figli-editable-cell" title="Cliquer pour modifier" @click="startEditCell(item, 'facture')">{{ item.facture || '—' }}</span>
</td>
<td class="figli-libelle">
<input
v-if="isEditingCell(item, 'libelle')"
v-focus
type="text"
class="figli-inline-input"
:value="item.libelle"
@change="saveCell(item, 'libelle', $event)"
@blur="editingCell = null"
@keyup.enter="$event.target.blur()"
/>
<template v-else>
<span class="figli-editable-cell" title="Cliquer pour modifier" @click="startEditCell(item, 'libelle')">{{ item.libelle || '—' }}</span>
<span
v-if="item.type === 'entree' && reconciliationByEntree.get(item.id) && reconciliationByEntree.get(item.id).count > 0"
class="figli-recon-badge is-clickable"
:class="{'is-anomalie': reconciliationByEntree.get(item.id).surVerse > 0, 'is-reste': reconciliationByEntree.get(item.id).resteAVerser > 0}"
:title="reconciliationByEntree.get(item.id).detail"
@click="toggleEntreeFilter(item.id)"
>{{ reconciliationByEntree.get(item.id).count }} sortie{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }} liée{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }}<template v-if="reconciliationByEntree.get(item.id).resteAVerser > 0"> · reste {{ formatEur(reconciliationByEntree.get(item.id).resteAVerser) }}</template><template v-if="reconciliationByEntree.get(item.id).surVerse > 0"> · sur-versé {{ formatEur(reconciliationByEntree.get(item.id).surVerse) }}</template></span>
<span
v-if="item.type === 'ouverture' && ouvertureEcart(item)"
class="figli-recon-badge is-anomalie"
:title="'Écart avec la clôture calculée de ' + (item.date.slice(0, 4) - 1) + ' : ' + ouvertureEcart(item).detail"
>⚠ écart clôture {{ item.date.slice(0, 4) - 1 }} ({{ ouvertureEcart(item).comptes }} compte{{ ouvertureEcart(item).comptes > 1 ? 's' : '' }})</span>
<span
v-if="linkStatus(item) && item.type !== 'hebergement'"
class="figli-recon-badge is-clickable"
:class="linkStatusClasses(linkStatus(item).kind)"
:title="item.entreeLieeIds.length ? linkStatus(item).detail + ' -- cliquer pour voir la ou les entrées liées' : linkStatus(item).detail + ' -- cliquer pour lier une entrée client'"
@click="item.entreeLieeIds.length ? toggleEntreeFilter(item.entreeLieeIds[0], item.nid) : openLinkForm(item.nid)"
>{{ linkStatus(item).kind === 'ok' ? '' : '⚠ ' }}{{ linkStatusLabel(linkStatus(item)) }}</span>
</template>
</td>
<td class="figli-flag-cell">
<span class="figli-editable-cell" title="Cliquer pour gérer le signalement" @click="openFlagModal(item)">
<template v-if="item.flags.length"><span v-for="f in item.flags" :key="f" class="figli-flag-badge">{{ f }}</span></template>
<template v-else>—</template>
</span>
</td>
<td class="amount" :class="montantClass(item.montant_ht)">{{ formatEur(item.montant_ht) }}</td>
<td class="amount">{{ formatEur(item.montant_ttc) }}</td>
@@ -125,19 +245,122 @@
<td class="amount" :class="{'figli-ecart': item.hasError}">{{ item.hasError ? formatEur(item.ecart) : '' }}</td>
</tr>
</template>
<tr ref="bottomSentinel" class="figli-sentinel-row">
<td :colspan="7 + allComptes.length + 3">
<span v-if="loadingNewer">Chargement des mois suivants…</span>
</td>
</tr>
</tbody>
<tfoot>
<tr class="figli-totals-row">
<td class="actions-col"></td>
<td class="actions-col"></td>
<td colspan="4">Solde (créditeur / débiteur) — {{ filteredRows.length }} lignes filtrées</td>
<td class="amount">{{ formatEur(footerTotals.montant_ht) }}</td>
<td class="amount">{{ formatEur(footerTotals.montant_ttc) }}</td>
<td v-for="c in allComptes" :key="c" class="amount compte-col" :class="soldeClass(footerTotals.parCompte[c])">{{ formatEur(footerTotals.parCompte[c]) }}</td>
<td class="amount" :class="soldeClass(footerTotals.ecart)">{{ formatEur(footerTotals.ecart) }}</td>
<td colspan="6">
Solde {{ currentYear || '…' }} (créditeur / débiteur)
<span v-if="currentYearLoading" class="figli-note">chargement…</span>
</td>
<td class="amount">{{ currentYearTotals ? formatEur(currentYearTotals.montant_ht) : '' }}</td>
<td class="amount">{{ currentYearTotals ? formatEur(currentYearTotals.montant_ttc) : '' }}</td>
<td v-for="c in allComptes" :key="c" class="amount compte-col" :class="currentYearTotals ? soldeClass(currentYearTotals.par_compte[c]) : ''">{{ currentYearTotals && currentYearTotals.par_compte[c] !== undefined ? formatEur(currentYearTotals.par_compte[c]) : '' }}</td>
<td class="amount" :class="currentYearTotals ? soldeClass(currentYearTotals.ecart) : ''">{{ currentYearTotals ? formatEur(currentYearTotals.ecart) : '' }}</td>
</tr>
</tfoot>
</table>
</div>
<div v-if="filterEntreeId" class="figli-modal-backdrop" @click.self="closeDrilldown">
<div class="figli-modal">
<div class="figli-modal-header">
<h3>Entrées et sorties liées <span v-if="groupLoading" class="figli-note">vérification…</span></h3>
<button type="button" class="figli-modal-close" @click="closeDrilldown">✕</button>
</div>
<div class="figli-modal-body">
<table>
<thead>
<tr>
<th class="actions-col"></th>
<th>Date</th>
<th>Client</th>
<th>Type</th>
<th>Facture</th>
<th>Libellé / Détail</th>
<th class="amount">Montant HT</th>
<th class="amount">Montant TTC</th>
<th v-for="c in modalComptes" :key="c" class="amount compte-col">{{ c }}</th>
<th class="amount">Écart</th>
</tr>
</thead>
<tbody>
<tr v-for="item in filterEntreeGroup" :key="item.id">
<td class="actions-col">
<button type="button" class="figli-edit-btn" title="Modifier" @click="openEditForm(item.nid)">
<svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M13.5 3.5l3 3L6 17l-3.5.5.5-3.5L13.5 3.5z" />
</svg>
</button>
</td>
<td>{{ formatDate(item.date) }}</td>
<td>{{ item.client || '—' }}</td>
<td><span class="figli-badge" :class="'type-' + item.type">{{ typeLabel(item.type) }}</span></td>
<td>{{ item.facture }}</td>
<td class="figli-libelle">
{{ item.libelle }}
<span
v-if="item.type === 'entree' && reconciliationByEntree.get(item.id) && reconciliationByEntree.get(item.id).count > 0"
class="figli-recon-badge"
:class="{'is-anomalie': reconciliationByEntree.get(item.id).surVerse > 0, 'is-reste': reconciliationByEntree.get(item.id).resteAVerser > 0}"
>{{ reconciliationByEntree.get(item.id).count }} sortie{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }} liée{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }}<template v-if="reconciliationByEntree.get(item.id).resteAVerser > 0"> · reste {{ formatEur(reconciliationByEntree.get(item.id).resteAVerser) }}</template><template v-if="reconciliationByEntree.get(item.id).surVerse > 0"> · sur-versé {{ formatEur(reconciliationByEntree.get(item.id).surVerse) }}</template></span>
<span
v-if="linkStatus(item) && item.type !== 'hebergement'"
class="figli-recon-badge"
:class="linkStatusClasses(linkStatus(item).kind)"
>{{ linkStatus(item).kind === 'ok' ? '' : '⚠ ' }}{{ linkStatusLabel(linkStatus(item)) }}</span>
</td>
<td class="amount" :class="montantClass(item.montant_ht)">{{ formatEur(item.montant_ht) }}</td>
<td class="amount">{{ formatEur(item.montant_ttc) }}</td>
<td v-for="c in modalComptes" :key="c" class="amount compte-col">{{ item.parCompte[c] !== undefined ? formatEur(item.parCompte[c]) : '' }}</td>
<td class="amount" :class="{'figli-ecart': item.hasError}">{{ item.hasError ? formatEur(item.ecart) : '' }}</td>
</tr>
</tbody>
<tfoot>
<tr class="figli-totals-row">
<td class="actions-col"></td>
<td colspan="5">Solde entrées et sorties liées (créditeur / débiteur)</td>
<td class="amount">{{ drilldownTotals ? formatEur(drilldownTotals.montant_ht) : '' }}</td>
<td class="amount">{{ drilldownTotals ? formatEur(drilldownTotals.montant_ttc) : '' }}</td>
<td v-for="c in modalComptes" :key="c" class="amount compte-col" :class="drilldownTotals ? soldeClass(drilldownTotals.par_compte[c]) : ''">{{ drilldownTotals && drilldownTotals.par_compte[c] !== undefined ? formatEur(drilldownTotals.par_compte[c]) : '' }}</td>
<td class="amount" :class="drilldownTotals ? soldeClass(drilldownTotals.ecart) : ''">{{ drilldownTotals ? formatEur(drilldownTotals.ecart) : '' }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div v-if="flagModalItem" class="figli-modal-backdrop" @click.self="closeFlagModal">
<div class="figli-modal figli-flag-modal">
<div class="figli-modal-header">
<h3>Signalement -- {{ formatDate(flagModalItem.date) }}{{ flagModalItem.client ? ' · ' + flagModalItem.client : '' }}</h3>
<button type="button" class="figli-modal-close" @click="closeFlagModal">✕ Fermer</button>
</div>
<div class="figli-flag-modal-body">
<p v-if="!flagModalItem.flags.length" class="figli-note">Aucun signalement pour cette ligne.</p>
<div v-for="f in flagModalItem.flags" :key="f" class="figli-flag-modal-row">
<span class="figli-flag-badge">{{ f }}</span>
<button type="button" class="figli-flag-remove" title="Retirer ce signalement" @click="removeFlagTag(f)">✕</button>
</div>
<div class="figli-flag-modal-add">
<input
v-focus
type="text"
list="figli-flag-datalist"
placeholder="Ajouter un signalement…"
v-model="flagModalNewTag"
@keyup.enter="addFlagTag"
/>
<button type="button" class="button" @click="addFlagTag">Ajouter</button>
</div>
</div>
</div>
</div>
</div>
{% endverbatim %}