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:
@@ -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
|
||||
|
||||
@@ -215,6 +215,9 @@ 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
|
||||
|
||||
@@ -188,6 +188,9 @@ 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);
|
||||
|
||||
|
||||
@@ -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
|
||||
// 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' => [
|
||||
@@ -63,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']],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
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
|
||||
@@ -29,6 +32,9 @@
|
||||
versement: '#d97a0a',
|
||||
achat: '#3b6fe0',
|
||||
hebergement: '#0e9182',
|
||||
sous_traitant: '#c9312b',
|
||||
salaire_stage: '#0891b2',
|
||||
charges_local_pro: '#65a30d',
|
||||
autre: '#9061f0',
|
||||
};
|
||||
|
||||
|
||||
@@ -29,16 +29,34 @@
|
||||
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',
|
||||
ouverture: 'Ouverture',
|
||||
};
|
||||
// Sorties that can be linked to the entrée client they pay out against
|
||||
// (field_entree_liee) -- charge/autre/ouverture aren't client-specific.
|
||||
const LINKABLE_TYPES = ['versement', 'achat', 'hebergement'];
|
||||
// (field_entree_liee) -- charge/autre/ouverture/salaire_stage/
|
||||
// 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 EXTEND_MONTHS = 6; // increment per scroll-triggered load
|
||||
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,
|
||||
// loadOlder()/loadNewer() kept pushing windowStart/windowEnd outward
|
||||
@@ -561,7 +579,7 @@
|
||||
// Trim the far (newer) end once the window's grown past the cap
|
||||
// -- happens below the current scroll position, so no visual
|
||||
// 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) {
|
||||
this.rows = this.rows.filter((r) => r.date < maxEnd);
|
||||
this.windowEnd = maxEnd;
|
||||
@@ -594,7 +612,7 @@
|
||||
// Trim the far (older) end once past the cap -- this IS above
|
||||
// the viewport, so compensate scrollTop the same way loadOlder
|
||||
// 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) {
|
||||
const wrap = this.$refs.tableWrap;
|
||||
const prevScrollHeight = wrap ? wrap.scrollHeight : 0;
|
||||
@@ -620,8 +638,53 @@
|
||||
const wrap = this.$refs.tableWrap;
|
||||
if (!wrap) return;
|
||||
const threshold = 400;
|
||||
if (wrap.scrollTop < threshold) this.loadOlder();
|
||||
if (wrap.scrollTop + wrap.clientHeight > wrap.scrollHeight - threshold) this.loadNewer();
|
||||
// No overflow at all -- typically a filter (compte/client/type/
|
||||
// é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
|
||||
// under the sticky thead) -- drives the totals footer. Works
|
||||
@@ -772,6 +835,22 @@
|
||||
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() {
|
||||
await this.load();
|
||||
@@ -791,6 +870,7 @@
|
||||
}
|
||||
this.detectCurrentYear();
|
||||
this.checkEdges();
|
||||
this.ensureScrollable();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -23,13 +23,16 @@ class LedgerActionsController extends ControllerBase {
|
||||
* allowed_values in config) -- validated against here rather than
|
||||
* 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
|
||||
* 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
|
||||
|
||||
Reference in New Issue
Block a user