Implémentation de la feature "suivi des objectifs"
This commit is contained in:
1 parent
6e03195d3a
commit
1060bfe2be
14 files changed
+1844
-192
No files matched your search
@@ -6,6 +6,7 @@ import EmptyState from '../components/EmptyState.jsx';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
import { useUi } from '../context/UiContext.jsx';
|
||||
import Modal from '../components/Modal.jsx';
|
||||
import ConfirmModal from '../components/ConfirmModal.jsx';
|
||||
import SoldeChart from '../components/SoldeChart.jsx';
|
||||
@@ -94,6 +95,7 @@ function platToJSON(rows) {
|
||||
/* ── Composant principal ─────────────────────────────────────── */
|
||||
export default function DepotsRetraits() {
|
||||
const { activeId, activeView, investisseurs } = useInvestisseur();
|
||||
const { objectifsActifs } = useUi();
|
||||
const { search } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState('plateformes');
|
||||
@@ -453,6 +455,12 @@ export default function DepotsRetraits() {
|
||||
totalPages: drTotalPages, totalItems: drTotalItems, PAGE_SIZES,
|
||||
} = usePagination(tableRows, 'cl_pagesize_dr', [filter]);
|
||||
|
||||
// Repli sur l'onglet Plateformes si la fixation des objectifs est désactivée
|
||||
// pendant qu'on est sur l'onglet Objectifs (ex. dans un autre onglet du navigateur).
|
||||
useEffect(() => {
|
||||
if (!objectifsActifs && activeTab === 'vision-annuelle') setActiveTab('plateformes');
|
||||
}, [objectifsActifs, activeTab]);
|
||||
|
||||
// Auto-sélectionne le premier mouvement quand la liste change dans l'onglet mouvements
|
||||
// (déclaré après `tableRows` pour éviter la temporal dead zone)
|
||||
useEffect(() => {
|
||||
@@ -575,6 +583,7 @@ export default function DepotsRetraits() {
|
||||
Toujours calculé sur le portefeuille entier (ignore le filtre plateforme),
|
||||
pour l'année sélectionnée (drPlatYear, ou l'année en cours si "Toutes les années"). */
|
||||
const scopeInvestisseurIdsKpi = activeView === 'all' ? investisseurs.map(i => i.id) : [activeId];
|
||||
|
||||
const kpiObjectifYear = Number(drPlatYear || new Date().getFullYear());
|
||||
const kpiObjectifTotal = (() => {
|
||||
const list = objectifsKpi.filter(o => o.annee === kpiObjectifYear && scopeInvestisseurIdsKpi.includes(o.investisseur_id));
|
||||
@@ -904,14 +913,16 @@ export default function DepotsRetraits() {
|
||||
<TrendBadge current={totals.depots - totals.retraits} prev={prevTotals.depots - prevTotals.retraits} />
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.depots - prevTotals.retraits)} en {prevYear}</div>
|
||||
{kpiEcartObjectif != null && (
|
||||
{objectifsActifs && kpiEcartObjectif != null && (
|
||||
<div style={{
|
||||
fontSize: '0.8em', marginTop: 4, fontWeight: 600,
|
||||
color: kpiEcartObjectif >= 0 ? 'var(--success)' : 'var(--warning)',
|
||||
color: Math.abs(kpiEcartObjectif) < 0.01 ? 'var(--success)' : kpiEcartObjectif >= 0 ? 'var(--danger)' : 'var(--warning)',
|
||||
}}>
|
||||
{kpiEcartObjectif >= 0
|
||||
? `+${fmtEUR(kpiEcartObjectif)} au-delà de l'objectif ${kpiObjectifYear}`
|
||||
: `Reste ${fmtEUR(-kpiEcartObjectif)} pour l'objectif ${kpiObjectifYear}`}
|
||||
{Math.abs(kpiEcartObjectif) < 0.01
|
||||
? `Conforme à l'objectif ${kpiObjectifYear}`
|
||||
: kpiEcartObjectif >= 0
|
||||
? `+${fmtEUR(kpiEcartObjectif)} au-delà de l'objectif ${kpiObjectifYear}`
|
||||
: `${fmtEUR(-kpiEcartObjectif)} en dessous de l'objectif ${kpiObjectifYear}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -929,16 +940,18 @@ export default function DepotsRetraits() {
|
||||
{!listFocused && <div className="dr-tabs">
|
||||
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('plateformes')}>Plateformes</button>
|
||||
<button className={`dr-tab${activeTab === 'vision-annuelle' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('vision-annuelle')}>Vision annuelle</button>
|
||||
<button className={`dr-tab${activeTab === 'vision-mensuelle' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('vision-mensuelle')}>Vision mensuelle</button>
|
||||
<button className={`dr-tab${activeTab === 'mouvements' ? ' active' : ''}`}
|
||||
onClick={() => { setActiveTab('mouvements'); setSelectedRow(r => r ?? (rows[0] || null)); }}>Mouvements</button>
|
||||
{objectifsActifs && (
|
||||
<button className={`dr-tab${activeTab === 'vision-annuelle' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('vision-annuelle')}>Objectifs</button>
|
||||
)}
|
||||
</div>}
|
||||
|
||||
{/* ====== ONGLET VISION ANNUELLE ====== */}
|
||||
{activeTab === 'vision-annuelle' && (
|
||||
{objectifsActifs && activeTab === 'vision-annuelle' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<SuiviObjectifs
|
||||
rows={allRows}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -6,6 +6,7 @@ import CategoriesInvSection from './settings/CategoriesInvSection.jsx';
|
||||
import SecteursInvSection from './settings/SecteursInvSection.jsx';
|
||||
import ComptesSection from './settings/ComptesSection.jsx';
|
||||
import MaFiscaliteSection from './settings/MaFiscaliteSection.jsx';
|
||||
import ObjectifsSection from './settings/ObjectifsSection.jsx';
|
||||
import DataCleanupSection from './settings/DataCleanupSection.jsx';
|
||||
import ImportsSection from './settings/ImportsSection.jsx';
|
||||
|
||||
@@ -18,6 +19,7 @@ function IconTag() { return <svg width="15" height="15" viewBox="0 0 24 24"
|
||||
function IconLayers() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>; }
|
||||
function IconGrid() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>; }
|
||||
function IconMyFiscal() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M20 7H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/><path d="M16 3H8l-2 4h12l-2-4z"/><line x1="12" y1="12" x2="12" y2="12.01"/></svg>; }
|
||||
function IconTarget() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>; }
|
||||
function IconBroom() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 21l9-9"/><path d="M12.22 6.22L17 1.5l5.5 5.5-4.72 4.78"/><path d="M5 17c.5-2 2-3.5 4-4.5l3.5 3.5c-1 2-2.5 3.5-4.5 4"/></svg>; }
|
||||
function IconUpload() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>; }
|
||||
|
||||
@@ -35,6 +37,7 @@ const NAV = [
|
||||
{ id: 'plateformes', label: 'Mes plateformes', icon: <IconServer /> },
|
||||
{ id: 'comptes', label: 'Mes comptes courants', icon: <IconWallet /> },
|
||||
{ id: 'ma-fiscalite', label: 'Ma fiscalité', icon: <IconMyFiscal /> },
|
||||
{ id: 'objectifs', label: 'Fixation des objectifs', icon: <IconTarget /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -87,6 +90,7 @@ export default function Settings() {
|
||||
{section === 'plateformes' && <PlateformesSection />}
|
||||
{section === 'comptes' && <ComptesSection />}
|
||||
{section === 'ma-fiscalite' && <MaFiscaliteSection />}
|
||||
{section === 'objectifs' && <ObjectifsSection />}
|
||||
{section === 'categories-inv' && <CategoriesInvSection />}
|
||||
{section === 'secteurs-inv' && <SecteursInvSection />}
|
||||
{section === 'nettoyage' && <DataCleanupSection />}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user