Amélioration de la gestion du patrimoine

This commit is contained in:
ocroguennec committed 2026-09-30 16:26:14 +02:00
1 parent efa5de79b8
commit eea69d77a4
22 files changed
+3212 -71

No files matched your search

+30
View File
@@ -11,6 +11,9 @@ import VerifyEmail from './pages/VerifyEmail.jsx';
import Layout from './components/Layout.jsx';
import Dashboard from './pages/Dashboard.jsx';
import DashboardPe from './pages/DashboardPe.jsx';
import DashboardPatrimoine from './pages/DashboardPatrimoine.jsx';
import LignePatrimoineDetail from './pages/LignePatrimoineDetail.jsx';
import ActifApercu from './pages/ActifApercu.jsx';
import DepotsRetraits from './pages/DepotsRetraits.jsx';
import DepotsRetraitsSimple from './pages/DepotsRetraitsSimple.jsx';
import Investissements from './pages/Investissements.jsx';
@@ -95,10 +98,23 @@ function RequireWorkspace({ type, children }) {
// portefeuille), à enrichir ensuite.
function WorkspaceHome() {
const { active } = useWorkspace() || {};
if (active && active.type === 'patrimoine') return <DashboardPatrimoine />;
if (active && active.type !== 'crowdlending') return <DashboardPe />;
return <Dashboard />;
}
// Chantier Workspace Patrimoine (28/09/26, demande Olivier) : contrairement au workspace
// Private Equity, Patrimoine n'a pas de modèle de page propre pour Plateformes / Investissements
// / Dépôts-Retraits / Remboursements / Frais / Fiscalité — c'est un dashboard consolidé en
// lecture seule (cadrage Olivier, AskUserQuestion "Un seul dashboard consolidé (recommandé pour
// démarrer)"), pas un 3e modèle de données à dupliquer partout comme PE l'a été. Les routeurs
// binaires ci-dessous (`type !== 'crowdlending'` → page PE) redirigent donc explicitement vers
// l'accueil pour ce type plutôt que de tomber silencieusement dans la branche PE (qui n'aurait
// aucun sens : /investissements-pe, /depots-retraits-pe, etc. n'existent pas pour Patrimoine).
function isPatrimoineActive(active) {
return !!(active && active.type === 'patrimoine');
}
// Route /depots-retraits : partagée entre tous les workspaces (17/09/26,
// demande explicite d'Olivier de réutiliser le code de la page crowdlending
// plutôt que d'en construire une séparée à chaque nouveau workspace). Bascule
@@ -111,6 +127,7 @@ function WorkspaceHome() {
// RequireWorkspace ci-dessus.
function DepotsRetraitsRouter() {
const { active } = useWorkspace() || {};
if (isPatrimoineActive(active)) return <Navigate to="/" replace />;
if (active && active.type !== 'crowdlending') return <DepotsRetraitsSimple />;
return <DepotsRetraits />;
}
@@ -124,6 +141,7 @@ function DepotsRetraitsRouter() {
// un 3e type de workspace apparaît un jour.
function RemboursementsRouter() {
const { active } = useWorkspace() || {};
if (isPatrimoineActive(active)) return <Navigate to="/" replace />;
if (active && active.type !== 'crowdlending') return <RemboursementsSimple />;
return <Remboursements />;
}
@@ -135,6 +153,7 @@ function RemboursementsRouter() {
// cf. project_workspaces_transformation.md.
function InvestissementsRouter() {
const { active } = useWorkspace() || {};
if (isPatrimoineActive(active)) return <Navigate to="/" replace />;
if (active && active.type !== 'crowdlending') return <InvestissementsPe />;
return <Investissements />;
}
@@ -144,6 +163,7 @@ function InvestissementsRouter() {
// frais, documents), à enrichir ensuite (cf. InvestissementPeDetail.jsx).
function InvestissementDetailRouter() {
const { active } = useWorkspace() || {};
if (isPatrimoineActive(active)) return <Navigate to="/" replace />;
if (active && active.type !== 'crowdlending') return <InvestissementPeDetail />;
return <InvestissementDetail />;
}
@@ -153,6 +173,7 @@ function InvestissementDetailRouter() {
// son propre indicateur "au moins un frais déclaré" (FraisOnly / FraisPeOnly ci-dessus).
function FraisRouter() {
const { active } = useWorkspace() || {};
if (isPatrimoineActive(active)) return <Navigate to="/" replace />;
if (active && active.type !== 'crowdlending') return <FraisPeOnly><FraisPe /></FraisPeOnly>;
return <FraisOnly><Frais /></FraisOnly>;
}
@@ -163,6 +184,7 @@ function FraisRouter() {
// cf. project_workspaces_transformation.md).
function PlateformesRouter() {
const { active } = useWorkspace() || {};
if (isPatrimoineActive(active)) return <Navigate to="/" replace />;
if (active && active.type !== 'crowdlending') return <PlateformesPe />;
return <Plateformes />;
}
@@ -173,6 +195,7 @@ function PlateformesRouter() {
// RequireWorkspace (elle l'était jusqu'ici, cf. route "taxreport" plus bas).
function TaxReportRouter() {
const { active } = useWorkspace() || {};
if (isPatrimoineActive(active)) return <Navigate to="/" replace />;
if (active && active.type !== 'crowdlending') return <TaxReportPe />;
return <TaxReport />;
}
@@ -228,6 +251,13 @@ export default function App() {
<Route path="remboursements" element={<RemboursementsRouter />} />
<Route path="frais" element={<FraisRouter />} />
<Route path="simul" element={<RequireWorkspace type="crowdlending"><SimulRemboursements /></RequireWorkspace>} />
{/* Chantier "Visualisation d'un actif" (relance Phase 3b, 30/09/26) — page de
gestion d'une ligne_patrimoine, atteinte depuis DashboardPatrimoine.jsx (cf.
claude/plan_visualisation_actif.md). */}
<Route path="patrimoine/lignes/:id" element={<RequireWorkspace type="patrimoine"><LignePatrimoineDetail /></RequireWorkspace>} />
{/* Étape 3 (30/09/26) — aperçu en lecture pour un compte/emprunt existant, cf.
claude/plan_visualisation_actif.md. */}
<Route path="patrimoine/actif/:type/:id" element={<RequireWorkspace type="patrimoine"><ActifApercu /></RequireWorkspace>} />
<Route path="taxreport" element={<TaxReportRouter />} />
{/* Assistant IA (26/09/26, demande Olivier) : n'est plus réservé au crowdlending —
visible et accessible aussi depuis l'espace Private Equity (cf. Layout.jsx, nav
+87 -43
View File
@@ -9,6 +9,7 @@ import Logo from './Logo.jsx';
import UserMenu from './UserMenu.jsx';
import NotificationBell from './NotificationBell.jsx';
import AiAssistant from './AiAssistant.jsx';
import PatrimoineAjoutModal from './PatrimoineAjoutModal.jsx';
/* ── Icônes nav ─────────────────────────────────────────────── */
const ICONS_BASE = '/api/icons-files/';
@@ -78,6 +79,11 @@ function ProjectSearch() {
// d'interroger systematiquement l'endpoint crowdlending. Meme critere que le pattern
// *Router() deja utilise ailleurs (cf. InvestissementDetailRouter, App.jsx).
const isPe = !!(activeWorkspace && activeWorkspace.type !== 'crowdlending');
// Chantier Workspace Patrimoine (28/09/26) : ce workspace n'a pas de table
// investissements/investissements_pe à interroger (dashboard consolidé en lecture seule,
// cf. App.jsx) — la recherche globale de projet reste donc vide plutôt que d'appeler un
// endpoint qui n'a pas de sens pour ce type.
const isPatrimoine = !!(activeWorkspace && activeWorkspace.type === 'patrimoine');
const [query, setQuery] = useState('');
const [allInv, setAllInv] = useState([]);
const [open, setOpen] = useState(false);
@@ -87,6 +93,7 @@ function ProjectSearch() {
/* Chargement (ou rechargement) des investissements */
const loadInv = useCallback(async () => {
if (isPatrimoine) { setAllInv([]); return; }
try {
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
const endpoint = isPe ? '/investissements-pe' : '/investissements';
@@ -95,7 +102,7 @@ function ProjectSearch() {
// commun "nom" pour eviter de dupliquer le filtre et le rendu ci-dessous.
setAllInv(rows.map(r => ({ ...r, nom: isPe ? r.nom_deal : r.nom_projet })));
} catch {}
}, [activeView, isPe]);
}, [activeView, isPe, isPatrimoine]);
useEffect(() => { loadInv(); }, [loadInv, activeId]);
@@ -290,10 +297,17 @@ export default function Layout() {
// Lien "Frais" (chantier "frais PE", 17/09/26) : visible dans les deux workspaces, chacun
// avec son propre indicateur "au moins un frais déclaré" (hasFrais / hasFraisPe).
const showFrais = isCrowdlendingActive ? hasFrais : hasFraisPe;
// Chantier Workspace Patrimoine (28/09/26, demande Olivier) : Patrimoine est un dashboard
// consolidé en lecture seule, sans page Plateformes/Investissements/Dépôts-Retraits/
// Remboursements/Frais/Fiscalité propre (cf. App.jsx, isPatrimoineActive) — ces liens de nav
// et les actions rapides de la topbar (Ajout Investissement, etc.) n'ont donc pas de sens ici
// et sont masqués. Tableau de bord et Assistant IA restent visibles (comme pour PE).
const isPatrimoine = !!(activeWorkspace && activeWorkspace.type === 'patrimoine');
const navigate = useNavigate();
const [navIcons, setNavIcons] = useState({});
const [aiEnabled, setAiEnabled] = useState(false);
const [burgerOpen, setBurgerOpen] = useState(false);
const [patrimoineAjoutOpen, setPatrimoineAjoutOpen] = useState(false);
const burgerRef = useRef(null);
// Auto-collapse en mode portrait sur tablette (768–1200px)
@@ -371,30 +385,34 @@ export default function Layout() {
isCrowdlendingActive, pour conserver l'ordre historique du menu crowdlending
(Plateformes, Investissements, Dépôts/Retraits, Remboursements, Frais...) au lieu
de faire remonter ces liens en tête — cf. capture d'Olivier, 17/09/26. */}
<NavLink to="/plateformes" title="Plateformes">
<NavIcon libFilename={navIcons.plateforme} Fallback={IconInvestments} /><span className="nav-label">Plateformes</span>
</NavLink>
<NavLink to="/investissements" title="Investissements">
<NavIcon libFilename={navIcons.investissement} Fallback={IconInvestments} /><span className="nav-label">Investissements</span>
</NavLink>
<NavLink to="/depots-retraits" title="Dépôts / Retraits">
<NavIcon libFilename={navIcons['depots-retraits']} Fallback={IconDeposits} /><span className="nav-label">Dépôts / Retraits</span>
</NavLink>
<NavLink to="/remboursements" title="Remboursements">
<NavIcon libFilename={navIcons.remboursement} Fallback={IconRepayments} /><span className="nav-label">Remboursements</span>
</NavLink>
{showFrais && (
<NavLink to="/frais" title="Frais">
<NavIcon libFilename={navIcons.frais} Fallback={IconFrais} /><span className="nav-label">Frais</span>
</NavLink>
{!isPatrimoine && (
<>
<NavLink to="/plateformes" title="Plateformes">
<NavIcon libFilename={navIcons.plateforme} Fallback={IconInvestments} /><span className="nav-label">Plateformes</span>
</NavLink>
<NavLink to="/investissements" title="Investissements">
<NavIcon libFilename={navIcons.investissement} Fallback={IconInvestments} /><span className="nav-label">Investissements</span>
</NavLink>
<NavLink to="/depots-retraits" title="Dépôts / Retraits">
<NavIcon libFilename={navIcons['depots-retraits']} Fallback={IconDeposits} /><span className="nav-label">Dépôts / Retraits</span>
</NavLink>
<NavLink to="/remboursements" title="Remboursements">
<NavIcon libFilename={navIcons.remboursement} Fallback={IconRepayments} /><span className="nav-label">Remboursements</span>
</NavLink>
{showFrais && (
<NavLink to="/frais" title="Frais">
<NavIcon libFilename={navIcons.frais} Fallback={IconFrais} /><span className="nav-label">Frais</span>
</NavLink>
)}
{/* Fiscalité (20/09/26, chantier "Fiscalité PE") a désormais son équivalent PE
(TaxReportPe.jsx, TaxReportRouter dans App.jsx) — sortie du bloc isCrowdlendingActive
pour devenir visible quel que soit le workspace actif, même principe que
Plateformes/Investissements/Dépôts-Retraits/Remboursements ci-dessus. */}
<NavLink to="/taxreport" title="Fiscalité">
<NavIcon libFilename={navIcons.tax} Fallback={IconFlatTax} /><span className="nav-label">Fiscalité</span>
</NavLink>
</>
)}
{/* Fiscalité (20/09/26, chantier "Fiscalité PE") a désormais son équivalent PE
(TaxReportPe.jsx, TaxReportRouter dans App.jsx) — sortie du bloc isCrowdlendingActive
pour devenir visible quel que soit le workspace actif, même principe que
Plateformes/Investissements/Dépôts-Retraits/Remboursements ci-dessus. */}
<NavLink to="/taxreport" title="Fiscalité">
<NavIcon libFilename={navIcons.tax} Fallback={IconFlatTax} /><span className="nav-label">Fiscalité</span>
</NavLink>
{/* Assistant IA (26/09/26, demande Olivier : absent de l'espace "Private Equity") —
même principe que les liens ci-dessus, sorti du bloc isCrowdlendingActive pour
rester visible (toujours en dernière position) quel que soit le workspace actif ;
@@ -418,15 +436,25 @@ export default function Layout() {
<ProjectSearch />
<div className="topbar-right">
{/* Boutons visibles sur grand écran */}
<button className="btn-add-invest topbar-action-btn" onClick={() => navigate('/investissements?new=1')}>
<IconPlusCircle />Ajout Investissement
</button>
<button className="btn-add-invest topbar-action-btn" onClick={() => navigate('/remboursements?new=1')}>
<IconPlusCircle />Nouveau remboursement
</button>
<button className="btn-add-invest topbar-action-btn" onClick={() => navigate('/depots-retraits?new=1')}>
<IconPlusCircle />Nouveau dépôt/retrait
</button>
{!isPatrimoine && (
<>
<button className="btn-add-invest topbar-action-btn" onClick={() => navigate('/investissements?new=1')}>
<IconPlusCircle />Ajout Investissement
</button>
<button className="btn-add-invest topbar-action-btn" onClick={() => navigate('/remboursements?new=1')}>
<IconPlusCircle />Nouveau remboursement
</button>
<button className="btn-add-invest topbar-action-btn" onClick={() => navigate('/depots-retraits?new=1')}>
<IconPlusCircle />Nouveau dépôt/retrait
</button>
</>
)}
{isPatrimoine && (
<button className="btn-add-invest topbar-action-btn" onClick={() => setPatrimoineAjoutOpen(true)}>
<IconPlusCircle />Compléter mon patrimoine
</button>
)}
{/* Bouton burger — visible sur tablette/mobile */}
<div className="topbar-burger-wrap" ref={burgerRef}>
@@ -444,16 +472,29 @@ export default function Layout() {
<svg width="15" height="15" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden="true"><circle cx="7.5" cy="7.5" r="5.5"/><line x1="11.5" y1="11.5" x2="16" y2="16"/></svg>
Rechercher un projet
</button>
<div className="topbar-burger-sep" />
<button className="topbar-burger-item" onClick={() => { setBurgerOpen(false); navigate('/investissements?new=1'); }}>
<IconPlusCircle />Ajout Investissement
</button>
<button className="topbar-burger-item" onClick={() => { setBurgerOpen(false); navigate('/remboursements?new=1'); }}>
<IconPlusCircle />Nouveau remboursement
</button>
<button className="topbar-burger-item" onClick={() => { setBurgerOpen(false); navigate('/depots-retraits?new=1'); }}>
<IconPlusCircle />Nouveau dépôt/retrait
</button>
{!isPatrimoine && (
<>
<div className="topbar-burger-sep" />
<button className="topbar-burger-item" onClick={() => { setBurgerOpen(false); navigate('/investissements?new=1'); }}>
<IconPlusCircle />Ajout Investissement
</button>
<button className="topbar-burger-item" onClick={() => { setBurgerOpen(false); navigate('/remboursements?new=1'); }}>
<IconPlusCircle />Nouveau remboursement
</button>
<button className="topbar-burger-item" onClick={() => { setBurgerOpen(false); navigate('/depots-retraits?new=1'); }}>
<IconPlusCircle />Nouveau dépôt/retrait
</button>
</>
)}
{isPatrimoine && (
<>
<div className="topbar-burger-sep" />
<button className="topbar-burger-item" onClick={() => { setBurgerOpen(false); setPatrimoineAjoutOpen(true); }}>
<IconPlusCircle />Compléter mon patrimoine
</button>
</>
)}
<div className="topbar-burger-sep" />
<div className="topbar-burger-toggle">
<span className="topbar-burger-toggle-label">Affichage</span>
@@ -491,6 +532,9 @@ export default function Layout() {
</main>
<AiAssistant />
{isPatrimoine && (
<PatrimoineAjoutModal open={patrimoineAjoutOpen} onClose={() => setPatrimoineAjoutOpen(false)} />
)}
</div>
);
}
@@ -0,0 +1,208 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import Modal from './Modal.jsx';
import { useWorkspace } from '../context/WorkspaceContext.jsx';
/**
* components/PatrimoineAjoutModal.jsx èè modale "+ Compléter mon patrimoine"
* (chantier du 30/09/26, demande Olivier). écran 1 : catégories (regroupements
* patrimoniaux racine, icône + courte explication). écran 2 : sous-choix d'actifs
* (enveloppes du référentiel rattachées à la catégorie choisie
* + ses enfants), chacune menant à la saisie (nouvelle ligne de patrimoine). Cf.
* claude/plan_visualisation_actif.md pour le cadrage complet (3 décisions via
* AskUserQuestion le 30/09/26).
*
* Cas particuliers :
* - Crowdlending / Private Equity (enfants de "Comptes d'investissement") : tuiles dédiées
* qui basculent le workspace actif puis ouvrent /plateformes èè même logique que
* le clic sur une ligne du tableau de bord Patrimoine (DashboardPatrimoine.jsx). Grisées
* si le workspace correspondant n'est pas actif pour l'utilisateur.
* - Passif (Emprunts) : pas d'enveloppe de référentiel (table `emprunts` séparée)
* èè une seule tuile "Nouvel emprunt" renvoyant vers Paramétres > Mes emprunts,
* même principe que le bouton "Modifier dans Paramétres" de ActifApercu.jsx.
*/
const I = ({ children }) => (
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{children}
</svg>
);
const IconImmobilier = () => <I><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></I>;
const IconComptesCourants = () => <I><rect x="1" y="4" width="22" height="16" rx="2"/><line x1="1" y1="10" x2="23" y2="10"/></I>;
const IconEpargne = () => <I><line x1="19" y1="5" x2="5" y2="19"/><circle cx="6.5" cy="6.5" r="2.5"/><circle cx="17.5" cy="17.5" r="2.5"/></I>;
const IconCrypto = () => <I><circle cx="12" cy="12" r="9"/><path d="M9 8.5c0-1 .9-1.5 2-1.5h1c1.4 0 2.5.9 2.5 2s-1.1 2-2.5 2h-1c-1.4 0-2.5.9-2.5 2s1.1 2 2.5 2h1c1.1 0 2-.5 2-1.5"/><line x1="12" y1="5.5" x2="12" y2="18.5"/></I>;
const IconInvestissement = () => <I><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></I>;
const IconAutresActifs = () => <I><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></I>;
const IconPassif = () => <I><circle cx="12" cy="12" r="10"/><polyline points="8 12 12 16 16 12"/><line x1="12" y1="8" x2="12" y2="16"/></I>;
const IconGenerique = () => <I><path d="M20.59 13.41L13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></I>;
const CATEGORY_META = {
'Immobilier': { icon: IconImmobilier, description: "Résidences, locatif, SCPI, OPCI, SCI…" },
'Comptes courants': { icon: IconComptesCourants, description: "Comptes courants rémunérés ou non." },
"Comptes d'épargne": { icon: IconEpargne, description: "Livrets réglementés ou bancaires, CEL, PEL…" },
'Crypto': { icon: IconCrypto, description: "Comptes exchange, wallets auto-conservés ou chez un tiers." },
"Comptes d'investissement": { icon: IconInvestissement, description: "Assurance-vie, PER, PEE, Bourse, Crowdlending, Private Equity…" },
'Autres actifs': { icon: IconAutresActifs, description: "Métaux précieux, objets de collection, forêt, vigne…" },
'Passif': { icon: IconPassif, description: "Emprunts en cours." },
};
const DEFAULT_CATEGORY_META = { icon: IconGenerique, description: '' };
function CategoryIcon({ nom }) {
const Comp = (CATEGORY_META[nom] || DEFAULT_CATEGORY_META).icon;
return <Comp />;
}
export default function PatrimoineAjoutModal({ open, onClose }) {
const navigate = useNavigate();
const { activeWorkspaces, setActiveWorkspace } = useWorkspace() || {};
const [step, setStep] = useState('categorie'); // 'categorie' | 'actif'
const [selected, setSelected] = useState(null);
const [regroupements, setRegroupements] = useState(null);
const [enveloppes, setEnveloppes] = useState(null);
const [err, setErr] = useState(null);
useEffect(() => {
if (!open) return;
setStep('categorie');
setSelected(null);
setErr(null);
Promise.all([
api.get('/regroupements-patrimoniaux-public'),
api.get('/enveloppes-referentiel-public'),
])
.then(([r, e]) => { setRegroupements(r); setEnveloppes(e); })
.catch(ex => setErr(ex.message));
}, [open]);
const racines = useMemo(
() => (regroupements || []).filter(r => !r.parent_id).sort((a, b) => a.ordre_affichage - b.ordre_affichage),
[regroupements]
);
function handleClose() { onClose(); }
// Important : on passe le regroupement_patrimonial_id propre de l'enveloppe (qui peut etre
// un enfant du regroupement affiche, ex. "Bourse" sous "Comptes d'investissement"), pas
// selected.id (le noeud racine cliqué a l'ecran 1) -- sinon le selecteur Enveloppe de
// LignePatrimoineDetail.jsx, filtre exactement sur regroupement_id, exclurait l'enveloppe
// pre-selectionnee (cf. son filtre enveloppesFiltrees).
function goToLigne(enveloppeId, regroupementId) {
navigate(`/patrimoine/lignes/nouveau?enveloppe_id=${enveloppeId}&regroupement_id=${regroupementId}`);
handleClose();
}
function goToWorkspace(wsType) {
const target = (activeWorkspaces || []).find(w => w.type === wsType);
if (!target) return;
setActiveWorkspace(target.id);
navigate('/plateformes');
handleClose();
}
function goToEmprunt() {
navigate('/settings?section=emprunts');
handleClose();
}
// écran 2 : construit les tuiles "sous-choix d'actifs" pour la catégorie choisie.
const actifTiles = useMemo(() => {
if (!selected || !regroupements || !enveloppes) return [];
if (selected.nom === 'Passif') {
return [{
key: 'emprunt',
nom: 'Nouvel emprunt',
description: "Montant emprunté, capital restant dû, taux, échéancier.",
onClick: goToEmprunt,
disabled: false,
}];
}
const enfants = regroupements.filter(r => r.parent_id === selected.id);
const enfantsPlateforme = enfants.filter(r => r.nom === 'Crowdlending' || r.nom === 'Private Equity');
const enfantsEnveloppe = enfants.filter(r => r.nom !== 'Crowdlending' && r.nom !== 'Private Equity');
const idsEnveloppes = [selected.id, ...enfantsEnveloppe.map(r => r.id)];
const tuilesEnveloppes = enveloppes
.filter(e => idsEnveloppes.includes(e.regroupement_patrimonial_id))
.sort((a, b) => a.nom.localeCompare(b.nom, 'fr'))
.map(e => ({
key: `env-${e.id}`,
nom: e.nom,
description: e.description || '',
onClick: () => goToLigne(e.id, e.regroupement_patrimonial_id),
disabled: false,
}));
const tuilesPlateforme = enfantsPlateforme.map(r => {
const wsType = r.nom === 'Crowdlending' ? 'crowdlending' : 'private_equity';
const target = (activeWorkspaces || []).find(w => w.type === wsType);
return {
key: `ws-${r.id}`,
nom: r.nom,
description: target
? `Géré depuis l'espace ${r.nom}.`
: `Espace ${r.nom} non activé pour votre compte.`,
onClick: () => goToWorkspace(wsType),
disabled: !target,
};
});
return [...tuilesEnveloppes, ...tuilesPlateforme];
}, [selected, regroupements, enveloppes, activeWorkspaces]);
return (
<Modal
open={open}
onClose={handleClose}
width={780}
title={step === 'categorie' ? 'Compléter mon patrimoine' : (
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button className="ghost" onClick={() => setStep('categorie')} title="Retour aux catégories" style={{ padding: '2px 8px' }}>←</button>
{selected?.nom}
</span>
)}
>
{err && <div className="error">{err}</div>}
{!err && !regroupements && <div className="text-muted">Chargement…</div>}
{!err && regroupements && step === 'categorie' && (
<div className="patrimoine-ajout-grid">
{racines.map(r => (
<button
key={r.id}
className="patrimoine-ajout-tile"
onClick={() => { setSelected(r); setStep('actif'); }}
>
<span className="patrimoine-ajout-tile-icon"><CategoryIcon nom={r.nom} /></span>
<span className="patrimoine-ajout-tile-title">{r.nom}</span>
<span className="patrimoine-ajout-tile-desc">{(CATEGORY_META[r.nom] || DEFAULT_CATEGORY_META).description}</span>
</button>
))}
</div>
)}
{!err && regroupements && step === 'actif' && (
<div className="patrimoine-ajout-grid">
{actifTiles.length === 0 && (
<div className="text-muted">Aucun type d'actif disponible dans cette catégorie pour le moment.</div>
)}
{actifTiles.map(t => (
<button
key={t.key}
className="patrimoine-ajout-tile"
onClick={t.onClick}
disabled={t.disabled}
title={t.disabled ? t.description : undefined}
>
<span className="patrimoine-ajout-tile-icon"><CategoryIcon nom={selected.nom} /></span>
<span className="patrimoine-ajout-tile-title">{t.nom}</span>
<span className="patrimoine-ajout-tile-desc">{t.description}</span>
</button>
))}
</div>
)}
</Modal>
);
}
@@ -0,0 +1,305 @@
import { useMemo, useState, useRef } from 'react';
/* ── Constantes ─────────────────────────────────────────────── */
const BLUE = 'var(--primary)';
const BG = 'var(--surface)';
const GRID = 'var(--border)';
const LABEL = 'var(--text-muted)';
const RANGES = ['1J', '7J', '1M', '3M', 'YTD', '1A', 'TOUT'];
const MOIS_COURT = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.'];
const W = 900, H = 260;
const PAD = { top: 16, right: 16, bottom: 32, left: 70 };
const plotW = W - PAD.left - PAD.right;
const plotH = H - PAD.top - PAD.bottom;
/* ── Helpers (identiques à ValorisationPeChart.jsx) ────────────── */
function fmtK(v) {
if (v === 0) return '0 €';
const abs = Math.abs(v);
if (abs >= 1000) return (v / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' k €';
return v.toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' €';
}
function fmtAxisDate(dateStr, range) {
const d = new Date(dateStr + 'T00:00:00');
const day = String(d.getDate()).padStart(2, '0');
const mon = MOIS_COURT[d.getMonth()];
const yr = d.getFullYear();
if (range === '1J' || range === '7J') return `${day}/${String(d.getMonth()+1).padStart(2,'0')}`;
if (range === '1M' || range === '3M') return `${day} ${mon}`;
return `${day}/${String(d.getMonth()+1).padStart(2,'0')}/${String(yr).slice(2)}`;
}
function fmtValueDisplay(v) {
return v.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }) + ' €';
}
/**
* Graphique "Historique de valeur" d'un actif de patrimoine (compte ou ligne_patrimoine) —
* chantier "Historique de valeur" (30/09/26, demande Olivier). Reprend la structure SVG
* dessinée à la main de ValorisationPeChart.jsx (mêmes constantes W/H/PAD, mêmes plages
* RANGES, même tooltip au survol), mais avec une sémantique plus simple : un seul actif, une
* seule série de points {date, valeur} — pas d'agrégation multi-deals. `rows` est le résultat
* brut de GET /api/valorisations-patrimoine?compte_id=...|ligne_patrimoine_id=... (déjà trié
* ASC par la route sur date_valorisation, id).
*
* Contrairement à ValorisationPeChart (qui prend 0 comme valeur de repli "avant tout deal"),
* il n'y a pas de "0 avant le premier point" naturel ici : un actif non encore suivi n'avait pas
* une valeur de 0, il n'était simplement pas encore renseigné. Le repli utilisé est donc la
* valeur du point le plus proche disponible (avant, sinon le premier après).
*/
export default function ValorisationPatrimoineChart({ rows, title = 'Historique de valeur' }) {
const [range, setRange] = useState('TOUT');
const [hover, setHover] = useState(null);
const svgRef = useRef(null);
const todayStr = new Date().toISOString().slice(0, 10);
/* ── 1. Normalisation de la série ── */
const allPoints = useMemo(() => {
if (!rows?.length) return [];
return rows
.filter(r => r.date_valorisation && r.valeur != null)
.map(r => ({ date: r.date_valorisation.slice(0, 10), value: r.valeur }));
}, [rows]);
const currentValue = allPoints.length ? allPoints[allPoints.length - 1].value : null;
const currentDate = allPoints.length ? allPoints[allPoints.length - 1].date : null;
/* ── 2. Filtrage par plage ── */
const points = useMemo(() => {
if (allPoints.length < 2) return [];
if (range === 'TOUT') {
const pts = [...allPoints];
if (pts[pts.length - 1].date < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value });
return pts;
}
const now = new Date(todayStr + 'T00:00:00');
let fromDate = new Date(now);
switch (range) {
case '1J': fromDate.setDate(now.getDate() - 1); break;
case '7J': fromDate.setDate(now.getDate() - 7); break;
case '1M': fromDate.setMonth(now.getMonth() - 1); break;
case '3M': fromDate.setMonth(now.getMonth() - 3); break;
case 'YTD': fromDate = new Date(now.getFullYear(), 0, 1); break;
case '1A': fromDate.setFullYear(now.getFullYear() - 1); break;
}
const fromStr = fromDate.toISOString().slice(0, 10);
const before = allPoints.filter(p => p.date < fromStr);
const after = allPoints.filter(p => p.date >= fromStr);
const startV = before.length ? before[before.length - 1].value : (after.length ? after[0].value : allPoints[0].value);
const pts = after.length ? [{ date: fromStr, value: startV }, ...after] : [{ date: fromStr, value: startV }];
if (pts[pts.length - 1].date < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value });
return pts;
}, [allPoints, range, todayStr]);
/* ── 3. Échelles ── */
const { xScale, yScale, yTicks, xTicks, minDate, maxDate, yZero } = useMemo(() => {
if (points.length < 2) return {};
const vals = points.map(p => p.value);
const dataMin = Math.min(...vals);
const dataMax = Math.max(...vals);
const lo = Math.min(0, dataMin);
const hi = Math.max(0, dataMax);
const pad = (hi - lo) * 0.1 || 10;
const scaleLo = lo - (lo < 0 ? pad : 0);
const scaleHi = hi + pad;
const valRange = scaleHi - scaleLo || 1;
const ts = points.map(p => new Date(p.date + 'T00:00:00').getTime());
const minDt = ts[0];
const maxDt = ts[ts.length - 1];
const dtRange = maxDt - minDt || 1;
const xScale = d => PAD.left + ((new Date(d + 'T00:00:00').getTime() - minDt) / dtRange) * plotW;
const yScale = v => PAD.top + plotH - ((v - scaleLo) / valRange) * plotH;
const step = (scaleHi - scaleLo) / 4;
const yTicks = Array.from({ length: 5 }, (_, i) => ({ v: scaleLo + step * i, y: yScale(scaleLo + step * i) }));
const nX = Math.min(8, points.length);
const xTicks = Array.from({ length: nX }, (_, i) => {
const idx = Math.round((i / (nX - 1)) * (points.length - 1));
return points[idx];
});
return { xScale, yScale, yTicks, xTicks, minDate: minDt, maxDate: maxDt, yZero: yScale(0) };
}, [points]);
/* ── 4. Chemins SVG ── */
const { linePath, areaPath } = useMemo(() => {
if (!xScale || points.length < 2) return { linePath: '', areaPath: '' };
let line = `M ${xScale(points[0].date).toFixed(1)},${yScale(points[0].value).toFixed(1)}`;
for (let i = 1; i < points.length; i++) {
line += ` H ${xScale(points[i].date).toFixed(1)} V ${yScale(points[i].value).toFixed(1)}`;
}
const zeroY = yZero?.toFixed(1) ?? (PAD.top + plotH).toFixed(1);
const area = `${line} V ${zeroY} H ${PAD.left} Z`;
return { linePath: line, areaPath: area };
}, [points, xScale, yScale, yZero]);
/* ── 5. Hover ── */
const handleMouseMove = (e) => {
if (!svgRef.current || !xScale || points.length < 2) return;
const rect = svgRef.current.getBoundingClientRect();
const svgX = ((e.clientX - rect.left) / rect.width) * W;
const t = minDate + ((svgX - PAD.left) / plotW) * (maxDate - minDate);
let nearest = points[0], minDiff = Infinity;
for (const p of points) {
const diff = Math.abs(new Date(p.date + 'T00:00:00').getTime() - t);
if (diff < minDiff) { minDiff = diff; nearest = p; }
}
setHover({ x: xScale(nearest.date), y: yScale(nearest.value), value: nearest.value, date: nearest.date });
};
/* ── Tooltip ── */
const tooltipStyle = useMemo(() => {
if (!hover) return null;
const xPct = (hover.x / W) * 100;
const yPct = (hover.y / H) * 100;
const anchorRight = xPct > 65;
return {
position: 'absolute',
top: `calc(${yPct}% - 64px)`,
left: anchorRight ? 'auto' : `calc(${xPct}% + 12px)`,
right: anchorRight ? `calc(${100 - xPct}% + 12px)` : 'auto',
transform: 'none',
pointerEvents: 'none',
};
}, [hover]);
if (!allPoints.length) return null;
// Moins de 2 points : rien à tracer (une droite a besoin de 2 points), on affiche juste la
// valeur connue et une note plutôt que rien du tout — meilleur que de masquer complètement
// le bloc, l'utilisateur comprend pourquoi il n'y a pas encore de courbe.
if (allPoints.length < 2) {
return (
<div className="solde-chart-wrap">
<div className="solde-chart-header">
<div className="solde-chart-info">
<div className="solde-chart-date">
{title} · {new Date(currentDate + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })}
</div>
<div className="solde-chart-value">{fmtValueDisplay(currentValue)}</div>
</div>
</div>
<p style={{ color: 'var(--text-muted)', fontSize: '13px', margin: '8px 0 0' }}>
Un seul point d'historique pour l'instant — la courbe apparaîtra dès la prochaine mise à jour de la valeur.
</p>
</div>
);
}
const displayDate = hover
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })
: new Date(currentDate + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' });
const displayValue = fmtValueDisplay(hover ? hover.value : currentValue);
const tooltipDate = hover
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
: null;
return (
<div className="solde-chart-wrap" style={{ position: 'relative' }}>
{/* ── En-tête ── */}
<div className="solde-chart-header">
<div className="solde-chart-info">
<div className="solde-chart-date">{title} · {displayDate}</div>
<div className="solde-chart-value">{displayValue}</div>
</div>
<div className="solde-chart-controls">
<div className="solde-chart-ranges">
{RANGES.map(r => (
<button key={r}
className={`solde-range-btn${range === r ? ' active' : ''}`}
onClick={() => { setRange(r); setHover(null); }}>
{r}
</button>
))}
</div>
</div>
</div>
{/* ── SVG ── */}
{xScale && (
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
style={{ width: '100%', height: 'auto', display: 'block', cursor: 'crosshair' }}
onMouseMove={handleMouseMove}
onMouseLeave={() => setHover(null)}
>
<defs>
<linearGradient id="patval-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={BLUE} stopOpacity="0.22" />
<stop offset="70%" stopColor={BLUE} stopOpacity="0.06" />
<stop offset="100%" stopColor={BLUE} stopOpacity="0" />
</linearGradient>
<filter id="patval-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
{/* Grille horizontale */}
{yTicks.map(({ v, y }) => (
<g key={v}>
<line x1={PAD.left} y1={y} x2={W - PAD.right} y2={y}
stroke={GRID} strokeWidth="1" strokeDasharray="3 5" />
<text x={PAD.left - 10} y={y + 4} textAnchor="end"
fill={LABEL} fontSize="11" fontFamily="system-ui,sans-serif">
{fmtK(v)}
</text>
</g>
))}
{/* Ligne zéro */}
{yZero && yZero > PAD.top && yZero < PAD.top + plotH && (
<line x1={PAD.left} y1={yZero} x2={W - PAD.right} y2={yZero}
stroke={GRID} strokeWidth="1" />
)}
{/* Aire dégradée + courbe */}
<path d={areaPath} fill="url(#patval-fill)" />
<path d={linePath} fill="none" stroke={BLUE} strokeWidth="1.5"
filter="url(#patval-glow)" strokeLinejoin="round" />
{/* Labels axe X */}
{xTicks.map((p, i) => {
const anchor = i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle';
return (
<text key={i} x={xScale(p.date)} y={H - 8}
textAnchor={anchor} fill={LABEL} fontSize="10"
fontFamily="system-ui,sans-serif">
{fmtAxisDate(p.date, range)}
</text>
);
})}
{/* Hover : ligne verticale + point */}
{hover && (
<g>
<line x1={hover.x} y1={PAD.top} x2={hover.x} y2={PAD.top + plotH}
stroke={GRID} strokeWidth="1" strokeDasharray="3 4" />
<circle cx={hover.x} cy={hover.y} r="4.5"
fill={BLUE} stroke={BG} strokeWidth="2" />
</g>
)}
</svg>
)}
{/* ── Tooltip flottant ── */}
{hover && tooltipStyle && (
<div style={tooltipStyle}>
<div className="sg-tooltip">
<span className="sg-tooltip-date">{tooltipDate}</span>
<span className="sg-tooltip-value">{fmtValueDisplay(hover.value)}</span>
</div>
</div>
)}
</div>
);
}
+167
View File
@@ -0,0 +1,167 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { api } from '../api.js';
import { fmtEUR, fmtDate } from '../utils/format.js';
import ValorisationPatrimoineChart from '../components/ValorisationPatrimoineChart.jsx';
/**
* pages/ActifApercu.jsx — page "Gestion d'un actif" (lecture) pour un compte ou un emprunt
* existant (chantier "Visualisation d'un actif", Étape 3, 30/09/26 — cf.
* claude/plan_visualisation_actif.md). Contrairement à LignePatrimoineDetail.jsx (lignes de
* patrimoine, CRUD complet), cette page est volontairement en lecture seule : décision #4
* d'Olivier ("Il faut en faire un lien [...] cela reste limité au type de compte qui sont
* selectionnables par choix dans le référentiel") — Paramètres reste la source des infos
* structurelles pour un compte/emprunt, cette page s'y lie plutôt que de les dupliquer.
*
* Pas de section Transactions ici (Point à valider #1, réponse recommandée retenue :
* comptes.solde_eur reste en saisie directe en v1, les transactions sont réservées aux lignes
* de patrimoine) ni de section Positions (non pertinent pour un compte/emprunt). Pas de section
* Paramétrage éditable : un bouton "Modifier dans Paramètres" renvoie vers l'écran existant
* (Paramètres > Mes comptes / Mes emprunts).
*
* Route `/patrimoine/actif/:type/:id` avec `type` = "compte" | "emprunt" (cf. App.jsx).
* Atteinte depuis DashboardPatrimoine.jsx en cliquant une ligne de ce type.
*/
const TYPE_META = {
compte: { label: 'Compte', settingsSection: 'comptes', listPath: '/comptes' },
emprunt: { label: 'Emprunt', settingsSection: 'emprunts', listPath: '/emprunts' },
};
export default function ActifApercu() {
const { type, id } = useParams();
const navigate = useNavigate();
const meta = TYPE_META[type];
const [row, setRow] = useState(null);
const [valorisations, setValorisations] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
useEffect(() => {
if (!meta) return;
setLoading(true);
setErr(null);
// Pas de GET /:id côté backend (comptes.js/emprunts.js n'exposent qu'un GET / — même
// patron que Paramètres > Mes comptes/Mes emprunts, qui chargent déjà toute la liste) : on
// réutilise la liste complète et on filtre côté client, sans ajouter de route backend pour
// un seul champ de lecture.
//
// Historique de valeur (30/09/26, demande Olivier) : uniquement pour les comptes — les
// emprunts sont explicitement hors périmètre de ce chantier (décision Olivier : "Non,
// actifs uniquement").
Promise.all([
api.get(meta.listPath),
type === 'compte' ? api.get('/valorisations-patrimoine', { compte_id: id }) : Promise.resolve([]),
])
.then(([rows, valo]) => {
const found = rows.find(r => String(r.id) === String(id));
if (!found) throw new Error('Actif introuvable');
setRow(found);
setValorisations(valo);
})
.catch(e => setErr(e.message))
.finally(() => setLoading(false));
}, [type, id]); // eslint-disable-line react-hooks/exhaustive-deps
if (!meta) return <div className="error">Type d'actif inconnu.</div>;
if (loading) return <div className="topbar"><h2>Chargement…</h2></div>;
if (err) return <div className="error">{err}</div>;
return (
<>
<div className="topbar">
<h2>
<button className="ghost" onClick={() => navigate('/')} title="Retour au tableau de bord Patrimoine" style={{ marginRight: 8 }}>← Patrimoine</button>
{row.nom}
</h2>
</div>
<div className="card" style={{ marginBottom: 20 }}>
<div className="kpi-grid">
{type === 'compte' && (
<div className="kpi">
<div className="label">Solde</div>
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4 }}>
{row.solde_eur == null
? <span className="text-muted" style={{ fontWeight: 400, fontStyle: 'italic', fontSize: '0.85rem' }}>Non renseigné</span>
: fmtEUR(row.solde_eur)}
</div>
</div>
)}
{type === 'emprunt' && (
<>
<div className="kpi">
<div className="label">Capital restant dû</div>
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(row.capital_restant_du)}</div>
</div>
<div className="kpi">
<div className="label">Montant emprunté</div>
<div style={{ fontSize: '1.1rem', fontWeight: 600, marginTop: 4 }}>{fmtEUR(row.montant_emprunte)}</div>
</div>
{row.taux_nominal != null && (
<div className="kpi">
<div className="label">Taux nominal</div>
<div style={{ fontSize: '1.1rem', fontWeight: 600, marginTop: 4 }}>{row.taux_nominal} %</div>
</div>
)}
{row.mensualite != null && (
<div className="kpi">
<div className="label">Mensualité</div>
<div style={{ fontSize: '1.1rem', fontWeight: 600, marginTop: 4 }}>{fmtEUR(row.mensualite)}</div>
</div>
)}
{row.date_debut && (
<div className="kpi">
<div className="label">Date de début</div>
<div style={{ fontSize: '1.1rem', fontWeight: 600, marginTop: 4 }}>{fmtDate(row.date_debut)}</div>
</div>
)}
{row.duree_mois != null && (
<div className="kpi">
<div className="label">Durée</div>
<div style={{ fontSize: '1.1rem', fontWeight: 600, marginTop: 4 }}>{row.duree_mois} mois</div>
</div>
)}
</>
)}
{row.enveloppe_nom && (
<div className="kpi">
<div className="label">Enveloppe</div>
<div style={{ fontSize: '1.1rem', fontWeight: 600, marginTop: 4 }}>{row.enveloppe_nom}</div>
</div>
)}
{(row.institution_nom || row.banque) && (
<div className="kpi">
<div className="label">Établissement</div>
<div style={{ fontSize: '1.1rem', fontWeight: 600, marginTop: 4 }}>{row.institution_nom || row.banque}</div>
</div>
)}
{row.investisseur_nom && (
<div className="kpi">
<div className="label">Détenteur</div>
<div style={{ fontSize: '1.1rem', fontWeight: 600, marginTop: 4 }}>{row.investisseur_nom}</div>
</div>
)}
</div>
</div>
{type === 'compte' && (
<div className="card" style={{ marginBottom: 20 }}>
<ValorisationPatrimoineChart rows={valorisations} />
</div>
)}
<div className="card">
<h3 style={{ marginTop: 0 }}>Paramétrage</h3>
<p className="text-muted" style={{ marginTop: 0 }}>
Les informations de ce {meta.label.toLowerCase()} (nom, établissement, enveloppe, détenteur{type === 'compte' ? ', solde' : ''})
se gèrent depuis Paramètres — cette page n'en est qu'un aperçu, pour rester la source unique de vérité.
</p>
<button className="primary" onClick={() => navigate(`/settings?section=${meta.settingsSection}`)}>
Modifier dans Paramètres
</button>
</div>
</>
);
}
+364
View File
@@ -0,0 +1,364 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PageIcon from '../components/PageIcon.jsx';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useWorkspace } from '../context/WorkspaceContext.jsx';
import { fmtEUR } from '../utils/format.js';
/**
* Tableau de bord du workspace "Patrimoine" (chantier du 28/09/26, demande Olivier — cf.
* claude/plan_workspace_patrimoine.md pour le cadrage complet). Vue consolidée en lecture
* seule par regroupement patrimonial (Admin > Regroupements — cf. AdminEnveloppes.jsx), un
* seul dashboard pour démarrer (décision Olivier, AskUserQuestion "Un seul dashboard
* consolidé (recommandé pour démarrer)"). Données : GET /api/dashboard-patrimoine (backend).
*
* Rendu en tableau (29/09/26, retour d'Olivier après avoir partagé des captures d'une autre
* application patrimoniale en référence : "rajouter un sorte d'organisation en tableau avec le
* nom, la répartition, le détenteur, la valeur, la +- values") — colonnes Nom / Répartition /
* Détenteur / Valeur / +/- value, lignes dépliables (regroupement → ses enfants + ses lignes de
* détail, cf. RegroupementRow). Remplace la précédente présentation en cartes empilées.
*
* Actif/Passif en onglets (30/09/26, retour d'Olivier avec capture d'écran à l'appui : "un
* système de tabulation avec actif et passif") — un seul tableau visible à la fois, via le
* composant .dr-tabs/.dr-tab déjà utilisé ailleurs dans l'app (Frais.jsx, TaxReport.jsx,
* Plateformes.jsx…). Remplace la présentation précédente où Actif et Passif étaient empilés
* l'un sous l'autre en permanence. L'onglet "Passifs" n'est même pas affiché s'il n'y a aucun
* regroupement de type passif (garde défensive — en pratique "Passif" existe toujours, seedé
* par db/index.js).
*
* "+/- value" est TOUJOURS affichée "—" avec une info-bulle explicative : contrairement aux
* captures de référence d'Olivier, l'app ne conserve aucun historique de valeur pour
* comptes.solde_eur ni emprunts.capital_restant_du (valeurs instantanées, écrasées à chaque
* saisie) — un vrai calcul de plus/moins-value nécessiterait de capturer un solde à chaque
* date, hors périmètre de ce chantier (cf. "Points ouverts", plan_workspace_patrimoine.md).
*
* "Répartition" est le poids de la ligne dans le total du tableau (total actif ou total passif
* selon la section) — une échelle commune à toutes les lignes, y compris les lignes de détail
* dépliées, pour rester comparable d'une ligne à l'autre quel que soit son niveau.
*
* "Détenteur" (29/09/26, retour d'Olivier, 3e puis 4e passe) n'est affiché QUE sur les lignes
* de détail (compte, emprunt, ou plateforme Crowdlending/Private Equity, cf. LigneRow) — jamais
* sur une ligne de regroupement (RegroupementRow, ex. "Comptes d'investissement", "Private
* Equity", "Crowdlending"), même quand toutes ses lignes partagent le même détenteur. Capture
* d'écran d'Olivier à l'appui, surlignant "Olivier CROGUENNEC" sur "Comptes d'investissement"/
* "Private Equity" et "Plusieurs" sur "Crowdlending" : *"Je voudrais que tu garde le nom des
* détenteurs uniquement pour les produits ou les plateformes."* La cellule Détenteur d'une
* RegroupementRow était d'abord passée à "—" fixe (sans agrégation — l'ancienne fonction
* detenteurAgrege, qui remontait un nom unique ou "Plusieurs" au niveau du regroupement, a été
* retirée), puis Olivier a demandé une cellule vraiment vide plutôt qu'un tiret : *"Je veux un
* vide à la place pas des tirets."* La cellule est donc désormais vide (aucun texte, aucun
* tiret) sur toute RegroupementRow.
*
* Notions à distinguer (propagées depuis le backend, cf. dashboardPatrimoine.js) :
* - `structurellement_vide` : regroupement qui ne peut recevoir AUCUN solde saisi aujourd'hui
* (Immobilier, Autres actifs) — Valeur et Répartition affichées "—".
* - `portefeuille_a_venir` : Crowdlending/Private Equity sans accès utilisateur — ne devrait
* jamais s'afficher en pratique (filtré par le useMemo `regroupements` ci-dessous), gardé
* en repli défensif.
* - `source_portefeuille` : Crowdlending/Private Equity avec accès — Valeur = capital investi
* réel du workspace dédié.
*/
function KpiCard({ title, value, note, color }) {
return (
<div className="kpi">
<div className="label">{title}</div>
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4, color: color || 'var(--text)' }}>{value}</div>
{note && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{note}</div>}
</div>
);
}
function IconChevron({ open }) {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6"
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"
style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .12s', flexShrink: 0, color: 'var(--text-muted)' }}>
<polyline points="9 6 15 12 9 18" />
</svg>
);
}
const PLUS_MOINS_VALUE_TITLE = "Non disponible : l'application ne conserve pas d'historique de valeur pour ces montants (soldes et capital investi sont des valeurs instantanées)";
function fmtPct(value, total) {
// value peut être null (compte "déclaré" en Paramètres sans solde saisi, cf. LigneRow) — la
// coercition JS (null / total) donnerait 0,0 %, ce qui laisserait croire à un poids nul dans
// le patrimoine plutôt qu'à une valeur simplement inconnue.
if (value == null || !total) return '—';
return `${(value / total * 100).toFixed(1).replace('.', ',')} %`;
}
// Texte + info-bulle de la cellule Valeur d'une ligne de regroupement — mêmes cas particuliers
// que l'ancienne RegroupementValue (cf. commentaire d'en-tête), condensés pour tenir dans une
// cellule de tableau.
function valeurRegroupement(node) {
if (node.structurellement_vide) {
return { text: '—', title: 'Pas encore de saisie possible ici (à venir avec le suivi détaillé du patrimoine)' };
}
if (node.portefeuille_a_venir) {
return { text: '—', title: 'Portefeuille géré dans le workspace dédié — non accessible pour votre compte' };
}
if (node.source_portefeuille) {
return { text: fmtEUR(node.total_eur), title: `Capital investi — portefeuille du workspace ${node.nom}` };
}
if (node.nb_comptes_avec_solde === 0 && node.nb_comptes_sans_solde === 0) {
return { text: fmtEUR(node.total_eur), title: 'Aucun compte rattaché' };
}
const detail = `${node.nb_comptes_avec_solde} compte${node.nb_comptes_avec_solde > 1 ? 's' : ''} renseigné${node.nb_comptes_avec_solde > 1 ? 's' : ''}`
+ (node.nb_comptes_sans_solde > 0 ? ` · ${node.nb_comptes_sans_solde} sans solde saisi` : '');
return { text: fmtEUR(node.total_eur), title: detail };
}
// Icône établissement/plateforme devant chaque ligne de détail (30/09/26, demande d'Olivier,
// capture à l'appui — une ligne "LDD Solidaire - Capucine" avec le logo Crédit Agricole devant
// le libellé) : "Peux-tu mettre en place des icones (plateformes ou institutions financières)
// devant les actifs". Le backend (dashboardPatrimoine.js) expose désormais icone_filename/
// logo_filename sur chaque ligne — institutions_referentiel pour les comptes/emprunts,
// plateformes pour Crowdlending/Private Equity — servis au même endroit que partout ailleurs
// dans l'app (/api/logos/:filename, cf. ComptesSection.jsx/PlatSelector.jsx) : icône carrée
// dédiée en priorité, repli sur le logo large si aucune icône n'a été téléversée, rien si ni
// l'un ni l'autre (compte en texte libre sans institution liée, plateforme sans logo).
const API_BASE = (import.meta.env.VITE_API_URL || '/api').replace(/\/api$/, '');
function ligneIconUrl(ligne) {
const filename = ligne.icone_filename || ligne.logo_filename;
return filename ? `${API_BASE}/api/logos/${filename}` : null;
}
// Ligne de détail (feuille — compte, emprunt, ou plateforme Crowdlending/Private Equity).
//
// 30/09/26, retour d'Olivier ("La déclaration des comptes courant faite dans les paramètres
// n'apparaissent pas") : un compte peut désormais apparaître ici avec `valeur_eur: null` — il a
// été "déclaré" (référencé) dans Paramètres, avec sa banque et son détenteur, sans qu'un solde
// ait été saisi. But initial de la déclaration selon Olivier : "indiquer d'où venait l'argent et
// où les remboursements pouvaient atterrir", pas nécessairement suivre un montant. On affiche
// alors "Non renseigné" plutôt qu'une cellule vide (fmtEUR(null) retourne '') ou trompeuse
// (fmtPct calculerait sinon 0,0 % via la coercition JS null → 0).
function LigneRow({ ligne, depth, grandTotal }) {
const navigate = useNavigate();
const sansValeur = ligne.valeur_eur == null;
const icon = ligneIconUrl(ligne);
// Étape 3 (30/09/26) — compte/emprunt ouvrent désormais ActifApercu.jsx (lecture seule,
// lien vers Paramètres) en plus des lignes_patrimoine (page CRUD complète). Les plateformes
// Crowdlending/Private Equity (type plateforme_cl/plateforme_pe) restent non cliquables ici
// — Étape 4, non implementée (cf. claude/plan_visualisation_actif.md).
const { activeWorkspaces, setActiveWorkspace } = useWorkspace() || {};
const isPlateforme = ligne.type === 'plateforme_cl' || ligne.type === 'plateforme_pe';
const clickable = ligne.type === 'ligne_patrimoine' || ligne.type === 'compte' || ligne.type === 'emprunt' || isPlateforme;
function handleClick() {
if (ligne.type === 'ligne_patrimoine') { navigate(`/patrimoine/lignes/${ligne.id}`); return; }
if (ligne.type === 'compte' || ligne.type === 'emprunt') { navigate(`/patrimoine/actif/${ligne.type}/${ligne.id}`); return; }
if (isPlateforme) {
const wsType = ligne.type === 'plateforme_cl' ? 'crowdlending' : 'private_equity';
const target = (activeWorkspaces || []).find(w => w.type === wsType);
// Workspace accorde (access.*) mais desactive par l'utilisateur (Parametres > Mes
// espaces de travail) : pas dans activeWorkspaces, donc pas de bascule possible ici -
// cette ligne n'aurait de toute facon pas du apparaitre (cf. access.crowdlending/
// private_equity cote backend), garde defensive plutot qu'un crash silencieux.
if (!target) return;
setActiveWorkspace(target.id);
navigate('/plateformes');
}
}
return (
<tr
style={clickable ? { cursor: 'pointer' } : undefined}
onClick={clickable ? handleClick : undefined}
>
<td style={{ paddingLeft: 14 + depth * 22 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 6 }}>
{icon && (
<img src={icon} alt=""
style={{ width: 18, height: 18, objectFit: 'contain', borderRadius: 3, flexShrink: 0, marginTop: 1 }} />
)}
<div>
<div style={{ fontWeight: 500, fontSize: '0.94em' }}>{ligne.label}</div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)' }}>
{ligne.sous_label || '—'}
{ligne.nb_lignes > 1 && ` · ${ligne.nb_lignes} investissements`}
</div>
</div>
</div>
</td>
<td className="text-muted">{fmtPct(ligne.valeur_eur, grandTotal)}</td>
<td className="text-muted">{ligne.detenteur || '—'}</td>
<td style={{ textAlign: 'right', fontWeight: 600 }}>
{sansValeur
? <span className="text-muted" style={{ fontWeight: 400, fontStyle: 'italic' }} title="Compte référencé dans Paramètres, sans solde saisi">Non renseigné</span>
: fmtEUR(ligne.valeur_eur)}
</td>
<td style={{ textAlign: 'right', color: 'var(--text-muted)' }} title={PLUS_MOINS_VALUE_TITLE}>—</td>
</tr>
);
}
// Bouton "+" par regroupement retire (chantier "Completer mon patrimoine", 30/09/26
// (suite), demande Olivier) : faisait doublon avec le bouton "+ Completer mon patrimoine" de
// la topbar (PatrimoineAjoutModal.jsx), qui couvre desormais tous les regroupements (pas
// seulement les structurellement_vide) et propose en plus le sous-choix d'actifs par icone.
// Ligne de regroupement (racine ou enfant, ex. Private Equity sous Comptes d'investissement) —
// dépliable dès qu'elle a au moins une ligne de détail ou un enfant.
function RegroupementRow({ node, depth, grandTotal, expanded, toggle }) {
const key = `r:${node.id}`;
const isOpen = expanded.has(key);
const hasChildren = (node.lignes && node.lignes.length > 0) || (node.enfants && node.enfants.length > 0);
const valeur = valeurRegroupement(node);
const pctText = (node.structurellement_vide || node.portefeuille_a_venir) ? '—' : fmtPct(node.total_eur, grandTotal);
return (
<>
<tr style={{ cursor: hasChildren ? 'pointer' : 'default' }} onClick={() => hasChildren && toggle(key)}>
<td style={{ paddingLeft: 10 + depth * 22 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontWeight: depth === 0 ? 600 : 500 }}>
{hasChildren ? <IconChevron open={isOpen} /> : <span style={{ width: 13, display: 'inline-block' }} />}
{node.nom}
</span>
</td>
<td className="text-muted">{pctText}</td>
<td className="text-muted"></td>
<td style={{ textAlign: 'right', fontWeight: 700 }} title={valeur.title}>{valeur.text}</td>
<td style={{ textAlign: 'right', color: 'var(--text-muted)' }} title={PLUS_MOINS_VALUE_TITLE}>—</td>
</tr>
{isOpen && node.lignes.map(l => (
<LigneRow key={`l:${l.id}`} ligne={l} depth={depth + 1} grandTotal={grandTotal} />
))}
{isOpen && node.enfants.map(child => (
<RegroupementRow key={`r:${child.id}`} node={child} depth={depth + 1} grandTotal={grandTotal} expanded={expanded} toggle={toggle} />
))}
</>
);
}
function RegroupementsTable({ nodes, grandTotal, expanded, toggle }) {
return (
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<table style={{ margin: 0 }}>
<thead>
<tr>
<th>Nom</th>
<th>Répartition</th>
<th>Détenteur</th>
<th style={{ textAlign: 'right' }}>Valeur</th>
<th style={{ textAlign: 'right' }}>+/- value</th>
</tr>
</thead>
<tbody>
{nodes.map(node => (
<RegroupementRow key={`r:${node.id}`} node={node} depth={0} grandTotal={grandTotal} expanded={expanded} toggle={toggle} />
))}
</tbody>
</table>
</div>
);
}
export default function DashboardPatrimoine() {
const { activeId, activeView } = useInvestisseur();
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [expanded, setExpanded] = useState(() => new Set());
// Onglets Actifs/Passifs (30/09/26, demande Olivier avec capture d'écran à l'appui : "un
// système de tabulation avec actif et passif") — même composant .dr-tabs/.dr-tab que le
// reste de l'app (Frais.jsx, TaxReport.jsx, Plateformes.jsx…), état local simple comme sur
// Frais.jsx (pas de persistance URL/localStorage nécessaire ici, un seul dashboard).
const [activeTab, setActiveTab] = useState('actifs');
// Filtre par détenteur actif (30/09/26, retour d'Olivier : "La sélection d'un détenteur ne
// semble pas filtrer le patrimoine") — même convention que Dashboard.jsx/DashboardPe.jsx :
// activeView === 'all' ("toute la famille") envoie ?scope=all, sinon le header
// X-Investisseur-Id (ajouté automatiquement par api.js depuis activeId) filtre sur le seul
// investisseur actif. On refetch à chaque changement de profil actif.
useEffect(() => {
if (!activeId && activeView !== 'all') return;
const scopeParams = activeView === 'all' ? { scope: 'all' } : undefined;
setLoading(true);
setErr(null);
api.get('/dashboard-patrimoine', scopeParams)
.then(setData)
.catch(e => setErr(e.message))
.finally(() => setLoading(false));
}, [activeId, activeView]);
const toggle = (key) => {
setExpanded(prev => {
const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
};
// Filtre "respect de l'accès utilisateur" (décision Olivier) : Crowdlending / Private Equity
// ne sont même pas affichés si l'utilisateur n'a pas accès au workspace correspondant — cf.
// backend routes/dashboardPatrimoine.js (access.crowdlending / access.private_equity).
//
// 30/09/26 : Crowdlending a été réintégré comme sous-regroupement de "Comptes
// d'investissement" (même statut que Private Equity, cf. claude/
// plan_regroupements_patrimoniaux.md) — il n'apparaît donc plus jamais au niveau racine de
// `data.regroupements`, seulement dans les `enfants` de "Comptes d'investissement". Le filtre
// par accès porte désormais uniquement sur les enfants, pour les deux noms.
const regroupements = useMemo(() => {
if (!data) return [];
return data.regroupements.map(r => ({
...r,
enfants: r.enfants.filter(c =>
(c.nom !== 'Private Equity' || data.access.private_equity) &&
(c.nom !== 'Crowdlending' || data.access.crowdlending)
),
}));
}, [data]);
const actifs = regroupements.filter(r => r.type === 'actif');
const passifs = regroupements.filter(r => r.type === 'passif');
return (
<>
<div className="topbar"><h2><PageIcon name="dashboard" />Tableau de bord — Patrimoine</h2></div>
{loading && <div className="text-muted" style={{ padding: '24px 0' }}>Chargement…</div>}
{err && <div className="error">{err}</div>}
{!loading && !err && data && (
<>
<div className="kpi-grid">
<KpiCard title="Total actif" value={fmtEUR(data.total_actif_eur)} note="Somme des soldes saisis et du capital investi" />
<KpiCard title="Total passif" value={fmtEUR(data.total_passif_eur)} />
<KpiCard
title="Patrimoine net"
value={fmtEUR(data.net_eur)}
color={data.net_eur >= 0 ? 'var(--success)' : 'var(--danger)'}
/>
</div>
<div className="dr-tabs" style={{ marginTop: 24 }}>
<button className={`dr-tab${activeTab === 'actifs' ? ' active' : ''}`}
onClick={() => setActiveTab('actifs')}>Actifs</button>
{passifs.length > 0 && (
<button className={`dr-tab${activeTab === 'passifs' ? ' active' : ''}`}
onClick={() => setActiveTab('passifs')}>Passifs</button>
)}
</div>
{activeTab === 'actifs' || passifs.length === 0 ? (
<RegroupementsTable nodes={actifs} grandTotal={data.total_actif_eur} expanded={expanded} toggle={toggle} />
) : (
<RegroupementsTable nodes={passifs} grandTotal={data.total_passif_eur} expanded={expanded} toggle={toggle} />
)}
<p style={{ marginTop: 24, fontSize: '0.85em', color: 'var(--text-muted)' }}>
Les soldes affichés proviennent de la saisie manuelle sur vos comptes (Paramètres
&gt; Mes comptes courants), à l'exception de Crowdlending et Private Equity, qui
reprennent le capital investi déjà affiché dans leur propre workspace. Les
regroupements marqués « — » ne sont pas encore couverts par cette vue consolidée.
La colonne « +/- value » n'est pas encore disponible (aucun historique de valeur
n'est conservé aujourd'hui).
</p>
</>
)}
</>
);
}
@@ -0,0 +1,466 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { api } from '../api.js';
import Modal from '../components/Modal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx';
import ValorisationPatrimoineChart from '../components/ValorisationPatrimoineChart.jsx';
import { fmtEUR, fmtDate, today } from '../utils/format.js';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
/**
* pages/LignePatrimoineDetail.jsx — page "Gestion d'un actif" pour une ligne_patrimoine
* (chantier "Visualisation d'un actif", relance Phase 3b, 30/09/26 — cf. demande d'Olivier :
* "j'aimerais travailler sur une notion de visualisation d'un actif [...] avoir un apercu de
* l'actif (graphique, KPI, etc..), [...] pouvoir rajouter la notion de transactions et une
* notion de parametrages" — cadrage complet dans claude/plan_visualisation_actif.md).
*
* Route `/patrimoine/lignes/:id` (`id` = "nouveau" pour la creation, cf. App.jsx) — atteinte
* depuis DashboardPatrimoine.jsx en cliquant une ligne de type `ligne_patrimoine`, ou le bouton
* "+" d'un regroupement structurellement vide (Immobilier, Autres actifs...), qui passe
* `?enveloppe_id=X&regroupement_id=Y` pour pre-remplir/filtrer le selecteur Enveloppe. Le
* bouton retour ramene toujours au dashboard patrimoine (route `/`, cf. WorkspaceHome).
*
* 3 sections, comme demande ("un apercu de l'actif [...], visualiser les positions [...] si
* pertinent, [...] transactions et [...] parametrages") : Apercu (valeur actuelle + contexte),
* Transactions (uniquement en edition — une ligne doit exister avant d'avoir des
* transactions), Parametrage (le formulaire de la ligne elle-meme, reutilise pour la creation).
* Pas de section "Positions" ici : les positions (=lignes d'investissement, cf. demande
* d'Olivier) concernent Crowdlending/Private Equity, gerees dans leurs workspaces dedies — hors
* perimetre d'une ligne_patrimoine, qui est une position unique par construction.
*
* Convention de signe reprise de la migration transactions_patrimoine (db/index.js) :
* depot/achat ajoutent, retrait/vente retranchent (saisis en positif dans le formulaire, comme
* valide cote Zod backend), reevaluation est une variation signee directe (peut etre negative).
*/
const TYPE_LABELS = { depot: 'Dépôt', retrait: 'Retrait', achat: 'Achat', vente: 'Vente', reevaluation: 'Réévaluation' };
function IcoDots() {
return <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.8" /><circle cx="12" cy="12" r="1.8" /><circle cx="12" cy="19" r="1.8" /></svg>;
}
function IcoEdit() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4Z" /></svg>;
}
function IcoTrash() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" /><path d="M10 11v6M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" /></svg>;
}
function MenuBtn({ onClick, icon, label, danger = false }) {
return (
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: danger ? 'var(--danger)' : 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={onClick}
>
{icon}{label}
</button>
);
}
function emptyTransForm() {
return { type: 'depot', montant: '', date_operation: today(), libelle: '', notes: '' };
}
function emptyLigneForm(enveloppeIdPrefill) {
return { nom: '', enveloppe_id: enveloppeIdPrefill || '', institution_id: '', investisseur_id: '', valeur_eur: '', notes: '' };
}
// Valeur courante calculee depuis un historique de transactions — meme formule que
// lignesPatrimoineStmt cote backend (dashboardPatrimoine.js) : depot/achat ajoutent,
// retrait/vente retranchent, reevaluation est une variation signee directe.
function valeurDepuisTransactions(transactions) {
if (transactions.length === 0) return null;
return transactions.reduce((s, t) => {
if (t.type === 'depot' || t.type === 'achat') return s + t.montant;
if (t.type === 'retrait' || t.type === 'vente') return s - t.montant;
return s + t.montant; // reevaluation
}, 0);
}
// Chantier "Historique de valeur" (30/09/26, demande Olivier) : quand une ligne a des
// transactions, sa valeur "officielle" est le cumul des transactions (valeurDepuisTransactions
// ci-dessus), pas la table valorisations_patrimoine (qui ne suit que la saisie directe
// valeur_eur, jamais mise a jour une fois que des transactions existent — cf.
// utils/valorisationsPatrimoine.js et lignesPatrimoine.js). Le graphique doit donc tracer CETTE
// serie cumulative-la, pas l'historique de valorisations_patrimoine, pour rester coherent avec
// la valeur actuelle affichee au-dessus. On reconstruit une serie au meme format que
// valorisations_patrimoine ({date_valorisation, valeur}) pour reutiliser
// ValorisationPatrimoineChart sans lui ajouter un second format d'entree.
function seriesDepuisTransactions(transactions) {
if (!transactions.length) return [];
const asc = [...transactions].sort((a, b) => a.date_operation.localeCompare(b.date_operation) || a.id - b.id);
let cum = 0;
const out = [];
for (const t of asc) {
if (t.type === 'depot' || t.type === 'achat') cum += t.montant;
else if (t.type === 'retrait' || t.type === 'vente') cum -= t.montant;
else cum += t.montant; // reevaluation
const d = t.date_operation.slice(0, 10);
if (out.length && out[out.length - 1].date_valorisation === d) {
out[out.length - 1].valeur = cum;
} else {
out.push({ date_valorisation: d, valeur: cum });
}
}
return out;
}
export default function LignePatrimoineDetail() {
const { id } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const isNew = id === 'nouveau';
const [ligne, setLigne] = useState(null);
const [transactions, setTransactions] = useState([]);
const [valorisations, setValorisations] = useState([]);
const [enveloppes, setEnveloppes] = useState([]);
const [institutions, setInstitutions] = useState([]);
const [investisseurs, setInvestisseurs] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [form, setForm] = useState(() => emptyLigneForm(searchParams.get('enveloppe_id')));
const { run, pending } = useAsyncAction();
const { run: runTrans, pending: pendingTrans } = useAsyncAction();
const [transModalOpen, setTransModalOpen] = useState(false);
const [transForm, setTransForm] = useState(emptyTransForm());
const [editingTransId, setEditingTransId] = useState(null);
const [deleteTransId, setDeleteTransId] = useState(null);
const [deleteLigneConfirm, setDeleteLigneConfirm] = useState(false);
const [openMenu, setOpenMenu] = useState(null);
const [saveErr, setSaveErr] = useState(null);
useEffect(() => {
setLoading(true);
setErr(null);
Promise.all([
api.get('/enveloppes-referentiel-public'),
api.get('/institutions-referentiel-public'),
api.get('/investisseurs'),
isNew ? Promise.resolve(null) : api.get(`/lignes-patrimoine/${id}`),
isNew ? Promise.resolve([]) : api.get('/transactions-patrimoine', { ligne_patrimoine_id: id }),
isNew ? Promise.resolve([]) : api.get('/valorisations-patrimoine', { ligne_patrimoine_id: id }),
])
.then(([env, inst, invs, lp, trans, valo]) => {
setEnveloppes(env);
setInstitutions(inst);
setInvestisseurs(invs);
if (lp) {
setLigne(lp);
setForm({
nom: lp.nom,
enveloppe_id: String(lp.enveloppe_id),
institution_id: lp.institution_id ? String(lp.institution_id) : '',
investisseur_id: lp.investisseur_id ? String(lp.investisseur_id) : '',
valeur_eur: lp.valeur_eur ?? '',
notes: lp.notes || '',
});
}
setTransactions(trans || []);
setValorisations(valo || []);
})
.catch(e => setErr(e.message))
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, isNew]);
const nbTransactions = transactions.length;
const valeurCalculee = useMemo(() => valeurDepuisTransactions(transactions), [transactions]);
const valeurActuelle = nbTransactions > 0
? valeurCalculee
: (form.valeur_eur !== '' ? Number(form.valeur_eur) : null);
const chartRows = useMemo(
() => (nbTransactions > 0 ? seriesDepuisTransactions(transactions) : valorisations),
[nbTransactions, transactions, valorisations]
);
// Filtre le selecteur Enveloppe au regroupement d'origine (query param a la creation, sinon
// celui de la ligne deja rattachee) — non contraint en base ni cote Zod (cf. commentaire
// d'en-tete de lignesPatrimoine.js), filtrage purement cote frontend, meme principe que le
// selecteur Enveloppe de Parametres > Mes comptes (restreint a eligible_compte=1, ici
// restreint au regroupement d'origine).
const regroupementId = searchParams.get('regroupement_id') || ligne?.regroupement_patrimonial_id;
const enveloppesFiltrees = useMemo(() => {
if (!regroupementId) return enveloppes;
return enveloppes.filter(e => String(e.regroupement_patrimonial_id) === String(regroupementId));
}, [enveloppes, regroupementId]);
function handleSaveLigne(e) {
e?.preventDefault?.();
setSaveErr(null);
return run(async () => {
try {
const body = {
nom: form.nom,
enveloppe_id: Number(form.enveloppe_id),
institution_id: form.institution_id ? Number(form.institution_id) : null,
investisseur_id: form.investisseur_id ? Number(form.investisseur_id) : null,
// La valeur saisie directement n'est envoyee que s'il n'y a pas de transaction — au
// backend elle reste stockee telle quelle (repli de lignesPatrimoineStmt), mais le
// champ est desactive cote UI des qu'une transaction existe pour eviter toute
// confusion sur laquelle des deux fait foi.
valeur_eur: form.valeur_eur !== '' ? Number(form.valeur_eur) : null,
notes: form.notes || null,
};
if (isNew) {
const created = await api.post('/lignes-patrimoine', body);
navigate(`/patrimoine/lignes/${created.id}`, { replace: true });
} else {
await api.put(`/lignes-patrimoine/${id}`, body);
const refreshed = await api.get(`/lignes-patrimoine/${id}`);
setLigne(refreshed);
}
} catch (err) {
setSaveErr(err.message);
}
});
}
function handleDeleteLigne() {
return run(async () => {
await api.del(`/lignes-patrimoine/${id}`);
navigate('/');
});
}
function openNewTrans() {
setEditingTransId(null);
setTransForm(emptyTransForm());
setTransModalOpen(true);
}
function openEditTrans(t) {
setEditingTransId(t.id);
setTransForm({ type: t.type, montant: String(t.montant), date_operation: t.date_operation, libelle: t.libelle || '', notes: t.notes || '' });
setTransModalOpen(true);
setOpenMenu(null);
}
function handleSaveTrans(e) {
e?.preventDefault?.();
return runTrans(async () => {
const body = {
ligne_patrimoine_id: Number(id),
date_operation: transForm.date_operation,
type: transForm.type,
montant: Number(transForm.montant),
libelle: transForm.libelle || null,
notes: transForm.notes || null,
};
if (editingTransId) {
await api.put(`/transactions-patrimoine/${editingTransId}`, body);
} else {
await api.post('/transactions-patrimoine', body);
}
const refreshed = await api.get('/transactions-patrimoine', { ligne_patrimoine_id: id });
setTransactions(refreshed);
setTransModalOpen(false);
});
}
function handleDeleteTrans() {
return runTrans(async () => {
await api.del(`/transactions-patrimoine/${deleteTransId}`);
const refreshed = await api.get('/transactions-patrimoine', { ligne_patrimoine_id: id });
setTransactions(refreshed);
setDeleteTransId(null);
});
}
const openMenuFor = (e, t) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setOpenMenu({ trans: t, x: rect.right, y: rect.bottom });
};
if (loading) return <div className="topbar"><h2>Chargement…</h2></div>;
if (err) return <div className="error">{err}</div>;
return (
<>
<div className="topbar">
<h2>
<button className="ghost" onClick={() => navigate('/')} title="Retour au tableau de bord Patrimoine" style={{ marginRight: 8 }}>← Patrimoine</button>
{isNew ? 'Nouvelle ligne de patrimoine' : (ligne?.nom || '—')}
</h2>
</div>
<div className="card" style={{ marginBottom: 20 }}>
<div className="kpi-grid">
<div className="kpi">
<div className="label">Valeur actuelle</div>
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4 }}>
{valeurActuelle != null ? fmtEUR(valeurActuelle) : '—'}
</div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{nbTransactions > 0
? `Calculee depuis ${nbTransactions} transaction${nbTransactions > 1 ? 's' : ''}`
: 'Saisie directe (aucune transaction enregistrée) — onglet Paramétrage'}
</div>
</div>
{ligne?.enveloppe_nom && (
<div className="kpi">
<div className="label">Enveloppe</div>
<div style={{ fontSize: '1.1rem', fontWeight: 600, marginTop: 4 }}>{ligne.enveloppe_nom}</div>
</div>
)}
{ligne?.institution_nom && (
<div className="kpi">
<div className="label">Établissement</div>
<div style={{ fontSize: '1.1rem', fontWeight: 600, marginTop: 4 }}>{ligne.institution_nom}</div>
</div>
)}
{ligne?.investisseur_nom && (
<div className="kpi">
<div className="label">Détenteur</div>
<div style={{ fontSize: '1.1rem', fontWeight: 600, marginTop: 4 }}>{ligne.investisseur_nom}</div>
</div>
)}
</div>
</div>
{!isNew && (
<div className="card" style={{ marginBottom: 20 }}>
<ValorisationPatrimoineChart rows={chartRows} />
</div>
)}
{!isNew && (
<div className="card" style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h3 style={{ margin: 0 }}>Transactions</h3>
<button onClick={openNewTrans}>+ Ajouter une transaction</button>
</div>
{transactions.length === 0 ? (
<div className="text-muted">Aucune transaction enregistrée — la valeur actuelle repose sur la saisie directe (onglet Paramétrage ci-dessous).</div>
) : (
<table className="tip-table">
<thead><tr><th>Date</th><th>Type</th><th>Montant</th><th>Libellé</th><th></th></tr></thead>
<tbody>
{transactions.map(t => (
<tr key={t.id}>
<td>{fmtDate(t.date_operation)}</td>
<td>{TYPE_LABELS[t.type] || t.type}</td>
<td>{fmtEUR(t.montant)}</td>
<td>{t.libelle || '—'}</td>
<td style={{ textAlign: 'right' }}>
<button onClick={e => openMenuFor(e, t)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '4px 6px', borderRadius: 4 }}>
<IcoDots />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
<div className="card">
<h3 style={{ marginTop: 0 }}>Paramétrage</h3>
{saveErr && <div className="error" style={{ marginBottom: 12 }}>{saveErr}</div>}
<form onSubmit={handleSaveLigne}>
<div className="form-grid">
<label>Nom
<input value={form.nom} onChange={e => setForm(f => ({ ...f, nom: e.target.value }))} required />
</label>
<label>Enveloppe
<select value={form.enveloppe_id} onChange={e => setForm(f => ({ ...f, enveloppe_id: e.target.value }))} required>
<option value="">—</option>
{enveloppesFiltrees.map(e => <option key={e.id} value={e.id}>{e.nom}</option>)}
</select>
</label>
<label>Établissement (optionnel)
<select value={form.institution_id} onChange={e => setForm(f => ({ ...f, institution_id: e.target.value }))}>
<option value="">—</option>
{institutions.map(i => <option key={i.id} value={i.id}>{i.nom}</option>)}
</select>
</label>
<label>Détenteur (optionnel)
<select value={form.investisseur_id} onChange={e => setForm(f => ({ ...f, investisseur_id: e.target.value }))}>
<option value="">—</option>
{investisseurs.map(i => <option key={i.id} value={i.id}>{i.nom}</option>)}
</select>
</label>
<label>
Valeur actuelle (€){nbTransactions > 0 && <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}> — calculee depuis les transactions, non modifiable ici</span>}
<input type="number" step="0.01" value={form.valeur_eur} disabled={nbTransactions > 0}
onChange={e => setForm(f => ({ ...f, valeur_eur: e.target.value }))} />
</label>
<label style={{ gridColumn: '1 / -1' }}>Notes
<textarea rows={3} value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} />
</label>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 16 }}>
{!isNew
? <button type="button" className="danger" onClick={() => setDeleteLigneConfirm(true)}>Supprimer cette ligne</button>
: <span />}
<button type="submit" className="primary" disabled={pending}>
{pending ? 'Enregistrement…' : (isNew ? 'Créer' : 'Enregistrer')}
</button>
</div>
</form>
</div>
{openMenu && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setOpenMenu(null)} />
<div style={{ position: 'fixed', left: openMenu.x, top: openMenu.y, transform: 'translateX(-100%) translateY(4px)', zIndex: 300, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)', padding: '4px 0', minWidth: 160 }}>
<MenuBtn icon={<IcoEdit />} label="Modifier" onClick={() => openEditTrans(openMenu.trans)} />
<MenuBtn icon={<IcoTrash />} label="Supprimer" danger onClick={() => { setDeleteTransId(openMenu.trans.id); setOpenMenu(null); }} />
</div>
</>
)}
<Modal
open={transModalOpen}
title={editingTransId ? 'Modifier la transaction' : 'Ajouter une transaction'}
onClose={() => setTransModalOpen(false)}
busy={pendingTrans}
footer={
<>
<button className="ghost" onClick={() => setTransModalOpen(false)} disabled={pendingTrans}>Annuler</button>
<button className="primary" onClick={handleSaveTrans} disabled={pendingTrans}>{pendingTrans ? 'Enregistrement…' : 'Enregistrer'}</button>
</>
}
>
<form onSubmit={handleSaveTrans}>
<div className="form-grid">
<label>Type
<select value={transForm.type} onChange={e => setTransForm(f => ({ ...f, type: e.target.value }))}>
{Object.entries(TYPE_LABELS).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</label>
<label>Date
<input type="date" value={transForm.date_operation} onChange={e => setTransForm(f => ({ ...f, date_operation: e.target.value }))} required />
</label>
<label>
Montant (€){transForm.type !== 'reevaluation' && <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}> — toujours positif</span>}
<input type="number" step="0.01" value={transForm.montant} onChange={e => setTransForm(f => ({ ...f, montant: e.target.value }))} required />
</label>
<label>Libellé (optionnel)
<input value={transForm.libelle} onChange={e => setTransForm(f => ({ ...f, libelle: e.target.value }))} />
</label>
</div>
</form>
</Modal>
<ConfirmModal
open={!!deleteTransId}
title="Supprimer la transaction"
message="Cette transaction sera définitivement supprimée."
onConfirm={handleDeleteTrans}
onCancel={() => setDeleteTransId(null)}
/>
<ConfirmModal
open={deleteLigneConfirm}
title="Supprimer cette ligne de patrimoine"
message="Cette ligne et toutes ses transactions seront définitivement supprimées."
onConfirm={handleDeleteLigne}
onCancel={() => setDeleteLigneConfirm(false)}
/>
</>
);
}
@@ -45,7 +45,7 @@ function Toggle({ checked, onClick, title }) {
// crowdlending/PE l'app affiche pour ce workspace). Choisi une seule fois à la création, comme
// le slug — jamais modifiable ensuite (changer le type d'un workspace déjà peuplé de données
// changerait silencieusement le modèle de page/API qui s'applique à ces données).
const WORKSPACE_TYPE_LABELS = { crowdlending: 'Crowdlending', private_equity: 'Private Equity' };
const WORKSPACE_TYPE_LABELS = { crowdlending: 'Crowdlending', private_equity: 'Private Equity', patrimoine: 'Patrimoine' };
// Valeurs pré-remplies suggérées selon le type choisi (retour d'Olivier, 20/09/26) — appliquées
// uniquement à la création et uniquement sur les champs encore vides (ne jamais écraser ce que
@@ -60,6 +60,10 @@ const WORKSPACE_TYPE_SUGGESTIONS = {
slug: 'private-equity', nom: 'Private Equity', libelleMenu: 'Private Equity',
description: 'Suivi des investissements de type Private Equity',
},
patrimoine: {
slug: 'patrimoine', nom: 'Patrimoine', libelleMenu: 'Patrimoine',
description: 'Vue consolidée du patrimoine par famille d\'actifs',
},
};
const emptyForm = { slug: '', nom: '', libelleMenu: '', description: '', type: '', actifGlobal: true, ordre: 0 };
@@ -162,10 +166,12 @@ function WorkspaceFormModal({ open, onClose, onSaved, workspace }) {
<option value="">— Choisir —</option>
<option value="crowdlending">Crowdlending</option>
<option value="private_equity">Private Equity</option>
<option value="patrimoine">Patrimoine</option>
</select>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
Détermine les pages affichées pour cet espace (Crowdlending ou Private Equity) — non
modifiable après création. Un seul espace de type Crowdlending peut exister.
Détermine les pages affichées pour cet espace (Crowdlending, Private Equity ou
Patrimoine) — non modifiable après création. Un seul espace de type Crowdlending et
un seul de type Patrimoine peuvent exister.
</span>
</div>
<div style={{ marginBottom: 12 }}>
+21 -3
View File
@@ -9,7 +9,7 @@ const EXONERATION_LABELS = {
aucune: 'Aucune',
pfnl_5ans: 'PFnl 5 ans',
};
const EMPTY_COMPTE = { nom: '', investisseur_id: null, institution_id: null, banque: '', enveloppe_id: null, exoneration_fiscale: 'aucune', compte_reglement_par_defaut: false };
const EMPTY_COMPTE = { nom: '', investisseur_id: null, institution_id: null, banque: '', enveloppe_id: null, exoneration_fiscale: 'aucune', compte_reglement_par_defaut: false, solde_eur: null };
// Référentiel institutions financières (Phase 2, 26/09/26) — logos servis au même endroit
// que ceux des plateformes (/api/logos/:filename), cf. institutionsReferentiel.js.
@@ -80,6 +80,16 @@ function CompteFormFields({ state, setter, investisseurs, institutions, envelopp
Rattachement au référentiel (Admin &gt; Enveloppes &amp; supports).
</p>
</div>
<div>
<label>Solde (€)</label>
<input type="number" step="0.01" value={state.solde_eur ?? ''}
onChange={e => setter({ ...state, solde_eur: e.target.value === '' ? null : Number(e.target.value) })}
placeholder="Non renseigné" />
<p style={{ margin: '4px 0 0', fontSize: 11, color: 'var(--text-muted)' }}>
Saisie manuelle — utilisé pour la vue patrimoine consolidée (espace de travail
Patrimoine).
</p>
</div>
<div>
<label>Détenteur</label>
<select value={state.investisseur_id ?? ''}
@@ -157,6 +167,7 @@ export default function ComptesSection() {
investisseur_id: newCompte.investisseur_id ? Number(newCompte.investisseur_id) : null,
exoneration_fiscale: newCompte.exoneration_fiscale ?? 'aucune',
compte_reglement_par_defaut: !!newCompte.compte_reglement_par_defaut,
solde_eur: newCompte.solde_eur ?? null,
});
setShowNew(false); setNewCompte(EMPTY_COMPTE); reload();
} catch (ex) { setErr(ex.message); }
@@ -173,6 +184,7 @@ export default function ComptesSection() {
investisseur_id: editCompte.investisseur_id ? Number(editCompte.investisseur_id) : null,
exoneration_fiscale: editCompte.exoneration_fiscale ?? 'aucune',
compte_reglement_par_defaut: !!editCompte.compte_reglement_par_defaut,
solde_eur: editCompte.solde_eur ?? null,
});
setEditCompte(null); reload();
} catch (ex) { setErr(ex.message); }
@@ -180,7 +192,7 @@ export default function ComptesSection() {
const openEdit = (c) => {
setErr(null);
setEditCompte({ id: c.id, nom: c.nom, banque: c.banque || '', institution_id: c.institution_id ?? null, enveloppe_id: c.enveloppe_id ?? null, investisseur_id: c.investisseur_id ?? null, exoneration_fiscale: c.exoneration_fiscale ?? 'aucune', compte_reglement_par_defaut: !!c.compte_reglement_par_defaut });
setEditCompte({ id: c.id, nom: c.nom, banque: c.banque || '', institution_id: c.institution_id ?? null, enveloppe_id: c.enveloppe_id ?? null, investisseur_id: c.investisseur_id ?? null, exoneration_fiscale: c.exoneration_fiscale ?? 'aucune', compte_reglement_par_defaut: !!c.compte_reglement_par_defaut, solde_eur: c.solde_eur ?? null });
};
const del = (c) => {
@@ -220,6 +232,7 @@ export default function ComptesSection() {
{multiDetenteur && <th style={{ width: '18%' }}>Détenteur</th>}
<th style={{ width: '14%' }}>Banque</th>
<th style={{ width: '16%' }}>Enveloppe</th>
<th style={{ width: '11%' }}>Solde</th>
<th style={{ width: '11%' }}>Défaut</th>
<th style={{ width: '9%' }}>Exonération</th>
<th style={{ width: 80 }} />
@@ -227,7 +240,7 @@ export default function ComptesSection() {
</thead>
<tbody>
{comptes.length === 0 && (
<tr><td colSpan={multiDetenteur ? 7 : 6} className="text-muted" style={{ textAlign: 'center', fontStyle: 'italic' }}>
<tr><td colSpan={multiDetenteur ? 8 : 7} className="text-muted" style={{ textAlign: 'center', fontStyle: 'italic' }}>
Aucun compte défini.
</td></tr>
)}
@@ -249,6 +262,11 @@ export default function ComptesSection() {
) : (c.banque || <span className="text-muted">—</span>)}
</td>
<td>{c.enveloppe_nom || <span className="text-muted">—</span>}</td>
<td>
{c.solde_eur != null
? c.solde_eur.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' })
: <span className="text-muted">—</span>}
</td>
<td>
{c.compte_reglement_par_defaut ? (
<span style={{ color: 'var(--primary)', fontWeight: 600, fontSize: 'var(--fs-sm)' }}>✓ Défaut</span>
+29
View File
@@ -905,6 +905,35 @@ a { color: var(--primary); }
}
.card h3 { font-size: 1rem; font-weight: 600; margin: 0 0 14px; }
/* Modale "Compléter mon patrimoine" (30/09/26) — grille de tuiles icone + titre + description */
.patrimoine-ajout-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 12px;
}
.patrimoine-ajout-tile {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
gap: 6px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px;
cursor: pointer;
transition: border-color .15s, box-shadow .15s, transform .1s;
}
.patrimoine-ajout-tile:hover:not(:disabled) {
border-color: var(--primary);
box-shadow: 0 3px 12px rgba(var(--primary-rgb),.18);
transform: translateY(-1px);
}
.patrimoine-ajout-tile:disabled { opacity: .5; cursor: not-allowed; }
.patrimoine-ajout-tile-icon { color: var(--primary); }
.patrimoine-ajout-tile-title { font-weight: 600; font-size: var(--fs-base); color: var(--text); }
.patrimoine-ajout-tile-desc { font-size: var(--fs-xs); color: var(--text-muted); line-height: 1.35; }
/* KPI grid */
.kpi-grid {
display: grid;