Implémentation de la feature "suivi des objectifs"

This commit is contained in:
ocroguennec committed 2026-08-17 17:15:50 +02:00
1 parent 6e03195d3a
commit 1060bfe2be
14 files changed
+1844 -192

No files matched your search

+127 -10
View File
@@ -7,7 +7,7 @@ import PageIcon from '../components/PageIcon.jsx';
import EmptyState from '../components/EmptyState.jsx';
import InvChart from '../components/InvChart.jsx';
import InvMensuelTable from '../components/InvMensuelTable.jsx';
import SuiviObjectifs from '../components/SuiviObjectifs.jsx';
import SuiviObjectifsCapital from '../components/SuiviObjectifsCapital.jsx';
import DrillCellPanel from '../components/DrillCellPanel.jsx';
import RembFormModal from '../components/RembFormModal.jsx';
import { InteretsChartProvider } from '../context/InteretsChartContext.jsx';
@@ -286,7 +286,7 @@ function AnneeSelector({ availableYears, selectedYear, onSelect }) {
═══════════════════════════════════════════════════════════════ */
export default function Plateformes() {
const { activeId, activeView, investisseurs } = useInvestisseur();
const { displayMode, chartInterets, chartCapital, chartCashback } = useUi();
const { displayMode, chartInterets, chartCapital, chartCashback, objectifsActifs } = useUi();
const navigate = useNavigate();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
@@ -355,6 +355,14 @@ export default function Plateformes() {
};
const [listFocused, setListFocused] = useState(false);
// Repli sur l'onglet Remboursements si la fixation des objectifs est
// désactivée pendant qu'on est sur l'onglet Objectifs (ex. tab restauré
// depuis l'URL ou le localStorage alors que le paramètre a été désactivé).
useEffect(() => {
if (!objectifsActifs && activeTab === 'objectifs') setActiveTab('remboursements');
// eslint-disable-next-line
}, [objectifsActifs, activeTab]);
/* ── Chargement ── */
const load = async () => {
if (!activeId && activeView !== 'all') return;
@@ -616,6 +624,10 @@ export default function Plateformes() {
/* ── Vrai/faux multi-détenteur global (indépendant de la plateforme sélectionnée) ── */
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
/* ── Vision "Multi-détenteur" active sur la plateforme sélectionnée — alimente
la ventilation par détenteur du tableau Objectifs (cf. detenteurOptions). ── */
const showMultiDetenteurBreakdown = !!detenteurOptions && selectedDetenteurId === 'all';
/* ── Reset détenteur quand plateforme change (skip restauration initiale) ── */
const isFirstPlatChange = useRef(true);
useEffect(() => {
@@ -636,6 +648,44 @@ export default function Plateformes() {
return new Set(matching.filter(p => String(p.investisseur_id) === String(selectedDetenteurId)).map(p => p.id));
}, [plats, selectedPlatName, selectedDetenteurId]);
/* ── IDs des plateformes du nom sélectionné, regroupés par détenteur —
alimente la ventilation par détenteur du tableau Objectifs quand la
plateforme est détenue par plusieurs personnes (cf. detenteurOptions). ── */
const platIdsByDetenteur = useMemo(() => {
if (!selectedPlatName) return {};
const map = {};
for (const p of plats) {
if (p.nom !== selectedPlatName) continue;
const key = String(p.investisseur_id);
(map[key] ??= new Set()).add(p.id);
}
return map;
}, [plats, selectedPlatName]);
/* ── Investissements filtrés sur la plateforme sélectionnée — alimente la
colonne "Capital investi" (encours à fin d'année) de SuiviObjectifs,
cf. onglet Dépôts/Retraits plus bas. remboursements/réinvestissements
ne sont pas filtrés ici : le rattachement se fait par investissement_id,
déjà scopé via cette liste. ── */
const investissementsForObjectifs = useMemo(
() => allRows.filter(inv => filteredPlatIds.size === 0 || filteredPlatIds.has(inv.plateforme_id)),
[allRows, filteredPlatIds]
);
const remboursementsCapitalForObjectifs = useMemo(
() => allRembs.filter(r => r.type === 'normal'),
[allRembs]
);
/* Objectifs de capital investi — pour enrichir le KPI "Capital investi" (écart
vs objectif de l'année affichée, sur le même scope plateforme/détenteur que
la page). Chargés une fois, indépendamment du filtre en cours. */
const [objectifsCapitalKpi, setObjectifsCapitalKpi] = useState([]);
useEffect(() => {
api.get('/objectifs', { type: 'capital_investi' })
.then(rows => setObjectifsCapitalKpi(Array.isArray(rows) ? rows : []))
.catch(() => setObjectifsCapitalKpi([]));
}, []);
/* ── Cellule "mois en cours" pour la section DrillCellPanel ── */
const MOIS_LONG_CELL = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
const moisEnCoursCell = useMemo(() => {
@@ -848,6 +898,23 @@ export default function Plateformes() {
const netMode = displayMode === 'net';
const prevYearLabel = selectedYear ? Number(selectedYear) - 1 : new Date().getFullYear() - 1;
/* ── Écart du KPI "Capital investi" vs l'objectif de capital investi fixé
dans Paramétrage, sur le même scope plateforme(s)/détenteur que la page.
Année de référence : celle sélectionnée (selectedYear), ou l'année en
cours si "Depuis le début". ── */
const kpiCapitalObjectifYear = Number(selectedYear || new Date().getFullYear());
const kpiCapitalHasPlatFilter = filteredPlatIds.size > 0;
const kpiCapitalScopeSet = useMemo(
() => new Set(activeView === 'all' ? investisseurs.map(i => i.id) : [activeId]),
[activeView, investisseurs, activeId]
);
const kpiCapitalObjectifTotal = useMemo(() => {
const list = objectifsCapitalKpi.filter(o => o.annee === kpiCapitalObjectifYear &&
(kpiCapitalHasPlatFilter ? filteredPlatIds.has(o.plateforme_id) : kpiCapitalScopeSet.has(o.investisseur_id)));
return list.length ? list.reduce((s, o) => s + o.montant, 0) : null;
}, [objectifsCapitalKpi, kpiCapitalObjectifYear, kpiCapitalHasPlatFilter, filteredPlatIds, kpiCapitalScopeSet]);
const kpiEcartCapitalObjectif = kpiCapitalObjectifTotal != null ? totals.encours - kpiCapitalObjectifTotal : null;
/* ── Solde porte-monnaie (source unique : GET /dashboard[?asOf=], cf. dashboard.js) ──
Snapshot backend filtré sur la sélection plateforme(s)/détenteur (filteredPlatIds). */
const soldeFromSnapshot = (snap) => {
@@ -1374,6 +1441,18 @@ export default function Plateformes() {
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(prevTotals.encours)} en {prevYearLabel}
</div>
{objectifsActifs && kpiEcartCapitalObjectif != null && (
<div style={{
fontSize: '0.8em', marginTop: 4, fontWeight: 600,
color: Math.abs(kpiEcartCapitalObjectif) < 0.01 ? 'var(--success)' : kpiEcartCapitalObjectif >= 0 ? 'var(--success)' : 'var(--warning)',
}}>
{Math.abs(kpiEcartCapitalObjectif) < 0.01
? `Conforme à l'objectif ${kpiCapitalObjectifYear}`
: kpiEcartCapitalObjectif >= 0
? `+${fmtEUR(kpiEcartCapitalObjectif)} au-delà de l'objectif ${kpiCapitalObjectifYear}`
: `${fmtEUR(-kpiEcartCapitalObjectif)} en dessous de l'objectif ${kpiCapitalObjectifYear}`}
</div>
)}
</div>
{/* 2 — Investissements à risque */}
@@ -1502,6 +1581,10 @@ export default function Plateformes() {
onClick={() => setActiveTab('investissements')}>Investissements</button>
<button className={`dr-tab${activeTab === 'mouvements-pm' ? ' active' : ''}`}
onClick={() => setActiveTab('mouvements-pm')}>Mouvements Porte-monnaie</button>
{objectifsActifs && (
<button className={`dr-tab${activeTab === 'objectifs' ? ' active' : ''}`}
onClick={() => setActiveTab('objectifs')}>Objectifs</button>
)}
</div>
{/* ====== ONGLET REMBOURSEMENTS ====== */}
@@ -2213,14 +2296,6 @@ export default function Plateformes() {
</div>
);
})()}
{/* Suivi des objectifs de versement — toujours sur le portefeuille entier,
indépendamment du filtre plateforme de cette page (cf. décision produit). */}
<SuiviObjectifs
rows={allDepots}
investisseurs={investisseurs}
scopeInvestisseurIds={activeView === 'all' ? investisseurs.map(i => i.id) : [activeId]}
/>
</div>
</div>
);
@@ -2685,6 +2760,48 @@ export default function Plateformes() {
</div>
)}
{/* ====== ONGLET OBJECTIFS ====== */}
{objectifsActifs && activeTab === 'objectifs' && (
<div style={{ padding: '0 24px' }}>
{/* Objectifs de capital investi : définis par plateforme dans Paramétrage >
Fixation des objectifs. Ici, lecture seule — comparaison du capital
réellement encore investi (encours) à l'objectif fixé, filtrée sur la
plateforme sélectionnée le cas échéant. */}
<SuiviObjectifsCapital
investissements={investissementsForObjectifs}
remboursementsCapital={remboursementsCapitalForObjectifs}
reinvestissements={allReinvests}
simulRembs={allSimulRembs}
scopeInvestisseurIds={activeView === 'all' ? investisseurs.map(i => i.id) : [activeId]}
plateformeIds={filteredPlatIds}
title={selectedPlatName
? `Suivi de l'objectif de capital investi — ${selectedPlatName}${showMultiDetenteurBreakdown ? ' (vision multi détenteur)' : ''}`
: 'Suivi de l\'objectif de capital investi'}
/>
{/* Plateforme détenue par plusieurs personnes et vue "Multi-détenteur"
active : la table ci-dessus agrège tous les détenteurs — on ajoute un
tableau complémentaire par détenteur, au même format, pour le détail. */}
{showMultiDetenteurBreakdown && detenteurOptions
.filter(opt => opt.id !== 'all')
.map(opt => {
const platIdsDet = platIdsByDetenteur[opt.id] || new Set();
return (
<SuiviObjectifsCapital
key={opt.id}
investissements={allRows.filter(inv => platIdsDet.has(inv.plateforme_id))}
remboursementsCapital={remboursementsCapitalForObjectifs}
reinvestissements={allReinvests}
simulRembs={allSimulRembs}
scopeInvestisseurIds={[Number(opt.id)]}
plateformeIds={platIdsDet}
title={`Suivi de l'objectif de capital investi — ${selectedPlatName} — ${opt.label}`}
/>
);
})}
</div>
)}
{/* Modal création / édition d'un mouvement (onglet "Mouvements Porte-monnaie") */}
<Modal
open={mvModalOpen}