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.
This commit is contained in:
2026-09-05 10:26:47 +02:00
parent 2f9ad7d52c
commit ef2d7b801d
7 changed files with 119 additions and 11 deletions
@@ -32,6 +32,15 @@ settings:
- -
value: hebergement value: hebergement
label: Hébergement 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: '' allowed_values_function: ''
module: options module: options
locked: false locked: false
@@ -215,6 +215,9 @@ html.gin--dark-mode #figli-home-app {
#figli-home-app .type-achat { background: #1d4ed81a; color: #3b6fe0; } #figli-home-app .type-achat { background: #1d4ed81a; color: #3b6fe0; }
#figli-home-app .type-hebergement { background: #0e91821a; color: #0e9182; } #figli-home-app .type-hebergement { background: #0e91821a; color: #0e9182; }
#figli-home-app .type-ouverture { background: #7c3aed1a; color: #9061f0; } #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); } #figli-home-app .type-autre { background: #6b72801a; color: var(--figli-text-light); }
/* Click-to-edit type badge -- swaps for a native <select> in place /* Click-to-edit type badge -- swaps for a native <select> in place
@@ -188,6 +188,9 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
'hebergement' => 'Hébergement', 'hebergement' => 'Hébergement',
'autre' => 'Autre', 'autre' => 'Autre',
'ouverture' => "Ligne d'ouverture", 'ouverture' => "Ligne d'ouverture",
'sous_traitant' => 'Sous-traitant',
'salaire_stage' => 'Salaire / stage',
'charges_local_pro' => 'Charges local pro',
], ],
], TRUE); ], TRUE);
@@ -53,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 // 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 // against a client invoice -- charge/autre/ouverture/salaire_stage
// specific entrée, and entree lines don't link to themselves. // 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'])) { if (isset($form['field_entree_liee'])) {
$form['field_entree_liee']['#states'] = [ $form['field_entree_liee']['#states'] = [
'visible' => [ 'visible' => [
@@ -63,6 +65,8 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state,
[':input[name="field_type_ligne"]' => ['value' => 'achat']], [':input[name="field_type_ligne"]' => ['value' => 'achat']],
'or', 'or',
[':input[name="field_type_ligne"]' => ['value' => 'hebergement']], [':input[name="field_type_ligne"]' => ['value' => 'hebergement']],
'or',
[':input[name="field_type_ligne"]' => ['value' => 'sous_traitant']],
], ],
]; ];
} }
@@ -18,6 +18,9 @@
versement: 'Versement freelance', versement: 'Versement freelance',
achat: 'Achat client', achat: 'Achat client',
hebergement: 'Hébergement', hebergement: 'Hébergement',
sous_traitant: 'Sous-traitant',
salaire_stage: 'Salaire / stage',
charges_local_pro: 'Charges local pro',
autre: 'Autre', autre: 'Autre',
}; };
// Stable colour per type, independent of sort order -- a viewer // Stable colour per type, independent of sort order -- a viewer
@@ -29,6 +32,9 @@
versement: '#d97a0a', versement: '#d97a0a',
achat: '#3b6fe0', achat: '#3b6fe0',
hebergement: '#0e9182', hebergement: '#0e9182',
sous_traitant: '#c9312b',
salaire_stage: '#0891b2',
charges_local_pro: '#65a30d',
autre: '#9061f0', autre: '#9061f0',
}; };
@@ -29,16 +29,34 @@
versement: 'Versement freelance', versement: 'Versement freelance',
achat: 'Achat client', achat: 'Achat client',
hebergement: 'Hébergement', hebergement: 'Hébergement',
sous_traitant: 'Sous-traitant',
salaire_stage: 'Salaire / stage',
charges_local_pro: 'Charges local pro',
autre: 'Autre', autre: 'Autre',
ouverture: 'Ouverture', ouverture: 'Ouverture',
}; };
// Sorties that can be linked to the entrée client they pay out against // Sorties that can be linked to the entrée client they pay out against
// (field_entree_liee) -- charge/autre/ouverture aren't client-specific. // (field_entree_liee) -- charge/autre/ouverture/salaire_stage/
const LINKABLE_TYPES = ['versement', 'achat', 'hebergement']; // charges_local_pro aren't client-specific.
const LINKABLE_TYPES = ['versement', 'achat', 'hebergement', 'sous_traitant'];
const WINDOW_MONTHS = 9; // each side of "today", for the initial load const WINDOW_MONTHS = 9; // each side of "today", for the initial load
const EXTEND_MONTHS = 6; // increment per scroll-triggered load const EXTEND_MONTHS = 6; // increment per scroll-triggered load
const MAX_LOADED_MONTHS = 30; // trim the far end once the window exceeds this const MAX_LOADED_MONTHS = 30; // trim the far end once the window exceeds this
// While a thinning filter (compte/client/type/écarts) is active, the cap
// above is actively counter-productive: extending one end and trimming
// the other nets out to roughly the same 30-month slice every round
// (see ensureScrollable()), so a filter with only a handful of matches
// per year can search almost forever without ever surfacing more of
// them. Comfortably wider than the whole migrated dataset (2021-today)
// so a filter effectively gets the entire history to search, still
// bounded (not literally unbounded memory) -- and DOM rendering cost
// stays proportional to filteredRows, not this raw fetched count.
const MAX_LOADED_MONTHS_FILTERED = 96;
// Hard cap on ensureScrollable()'s recursion (see below) -- comfortably
// more than enough rounds to walk the entire MIN_LOADABLE_DATE..
// MAX_LOADABLE_DATE span at EXTEND_MONTHS per round.
const MAX_ENSURE_SCROLLABLE_ROUNDS = 30;
// Absolute bounds on how far the window can extend. Without these, // Absolute bounds on how far the window can extend. Without these,
// loadOlder()/loadNewer() kept pushing windowStart/windowEnd outward // loadOlder()/loadNewer() kept pushing windowStart/windowEnd outward
@@ -561,7 +579,7 @@
// Trim the far (newer) end once the window's grown past the cap // Trim the far (newer) end once the window's grown past the cap
// -- happens below the current scroll position, so no visual // -- happens below the current scroll position, so no visual
// jump to compensate for. // jump to compensate for.
const maxEnd = addMonths(this.windowStart, MAX_LOADED_MONTHS); const maxEnd = addMonths(this.windowStart, this.isFiltering() ? MAX_LOADED_MONTHS_FILTERED : MAX_LOADED_MONTHS);
if (this.windowEnd > maxEnd) { if (this.windowEnd > maxEnd) {
this.rows = this.rows.filter((r) => r.date < maxEnd); this.rows = this.rows.filter((r) => r.date < maxEnd);
this.windowEnd = maxEnd; this.windowEnd = maxEnd;
@@ -594,7 +612,7 @@
// Trim the far (older) end once past the cap -- this IS above // Trim the far (older) end once past the cap -- this IS above
// the viewport, so compensate scrollTop the same way loadOlder // the viewport, so compensate scrollTop the same way loadOlder
// does for its prepend. // does for its prepend.
const minStart = addMonths(this.windowEnd, -MAX_LOADED_MONTHS); const minStart = addMonths(this.windowEnd, this.isFiltering() ? -MAX_LOADED_MONTHS_FILTERED : -MAX_LOADED_MONTHS);
if (this.windowStart < minStart) { if (this.windowStart < minStart) {
const wrap = this.$refs.tableWrap; const wrap = this.$refs.tableWrap;
const prevScrollHeight = wrap ? wrap.scrollHeight : 0; const prevScrollHeight = wrap ? wrap.scrollHeight : 0;
@@ -620,8 +638,53 @@
const wrap = this.$refs.tableWrap; const wrap = this.$refs.tableWrap;
if (!wrap) return; if (!wrap) return;
const threshold = 400; const threshold = 400;
if (wrap.scrollTop < threshold) this.loadOlder(); // No overflow at all -- typically a filter (compte/client/type/
if (wrap.scrollTop + wrap.clientHeight > wrap.scrollHeight - threshold) this.loadNewer(); // écarts) has thinned the visible rows enough that the loaded
// window's worth of matches doesn't fill the viewport. scrollTop
// is stuck at 0 either way, so neither of the two checks below
// means anything on its own; try both directions instead of
// waiting for a scroll that can never happen with nothing to
// scroll.
const notScrollable = wrap.scrollHeight <= wrap.clientHeight;
if (notScrollable || wrap.scrollTop < threshold) this.loadOlder();
if (notScrollable || wrap.scrollTop + wrap.clientHeight > wrap.scrollHeight - threshold) this.loadNewer();
},
// Whether compte/client/type/écarts thin filteredRows down from
// whatever's actually loaded -- loadOlder()/loadNewer() use this to
// widen the trim cap (MAX_LOADED_MONTHS_FILTERED instead of
// MAX_LOADED_MONTHS), since the normal cap actively works against a
// sparse filter: extending one end and immediately trimming the
// other off nets out to almost the same slice every round, so a
// filter with only a few matches a year could search almost forever
// without surfacing more of them.
isFiltering() {
return !!(this.filterCompte || this.filterClient || this.filterType || this.onlyErrors);
},
// Keeps extending the window (both directions) as long as a filter
// leaves too few matching rows to fill the viewport -- otherwise
// there's never another scroll event to hang further loading off
// of, and the sliding window silently stops responding the moment a
// filter (compte/client/type/écarts) makes the visible page short
// enough to not need a scrollbar.
//
// Bounded by a round count regardless, as a safety net: addMonths()
// uses JS's Date#setMonth(), which isn't invertible for month-end
// dates (e.g. 2025-03-31 minus 1 month lands on 2025-03-03, not
// 2025-02-28, because setMonth(1) overflows February), so even with
// the widened cap above, "no change since last round" isn't a
// guaranteed way to detect "there's genuinely nothing more to find".
async ensureScrollable(round = 0) {
if (this.filterYear || round >= MAX_ENSURE_SCROLLABLE_ROUNDS) return;
const wrap = this.$refs.tableWrap;
if (!wrap) return;
await this.$nextTick();
if (wrap.scrollHeight > wrap.clientHeight) return;
const beforeStart = this.windowStart;
const beforeEnd = this.windowEnd;
await this.loadOlder();
await this.loadNewer();
if (this.windowStart === beforeStart && this.windowEnd === beforeEnd) return;
await this.ensureScrollable(round + 1);
}, },
// Which year is "at the top" of the visible area right now (just // Which year is "at the top" of the visible area right now (just
// under the sticky thead) -- drives the totals footer. Works // under the sticky thead) -- drives the totals footer. Works
@@ -772,6 +835,22 @@
this.exitYearMode(); this.exitYearMode();
} }
}, },
// Any of these can thin filteredRows enough to remove the
// scrollbar the sliding window relies on to keep loading -- see
// ensureScrollable(). filterYear is handled separately above (it
// swaps the whole loading strategy, not just the visible subset).
filterCompte() {
this.ensureScrollable();
},
filterClient() {
this.ensureScrollable();
},
filterType() {
this.ensureScrollable();
},
onlyErrors() {
this.ensureScrollable();
},
}, },
async mounted() { async mounted() {
await this.load(); await this.load();
@@ -791,6 +870,7 @@
} }
this.detectCurrentYear(); this.detectCurrentYear();
this.checkEdges(); this.checkEdges();
this.ensureScrollable();
}, },
}; };
@@ -23,13 +23,16 @@ class LedgerActionsController extends ControllerBase {
* allowed_values in config) -- validated against here rather than * allowed_values in config) -- validated against here rather than
* trusting the client. * trusting the client.
*/ */
const ALLOWED_TYPES = ['entree', 'charge', 'versement', 'achat', 'hebergement', 'autre', 'ouverture']; 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 * Types field_entree_liee is meaningful for -- mirrors the #states
* visibility rule in figli_compta_ledger_form_alter(). * 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']; const LINKABLE_TYPES = ['versement', 'achat', 'hebergement', 'sous_traitant'];
/** /**
* POST /lignes/{node}/type -- change field_type_ligne without opening * POST /lignes/{node}/type -- change field_type_ligne without opening