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
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user