Améliorations diverses

This commit is contained in:
ocroguennec committed 2026-09-19 19:00:12 +02:00
1 parent 8817ac693d
commit 49fab06012
15 files changed
+905 -144

No files matched your search

+12
View File
@@ -3445,6 +3445,18 @@ if (!invPeCols.includes('capital_investissable')) {
`);
}
// Mode de détention du deal PE (19/09/26, demande Olivier) — prépare le terrain pour un futur
// calcul fiscal PE sans le construire maintenant (cf. project_workspaces_transformation.md) :
// 'direct' (compte-titres classique, flat tax au moment du versement, ex. Fundora) | 'pea_pme'
// (enveloppe PEA/PEA-PME, ex. Baltis) | 'assurance_vie'. Même principe que fiscalite_override
// côté crowdlending (cf. project_fiscalite_override.md), mais en énumération à 3 valeurs plutôt
// qu'un booléen "exonéré ou pas" : PEA-PME et assurance-vie ont des régimes fiscaux différents
// l'un de l'autre, pas juste "exonéré ou pas". Informatif pour l'instant, aucun calcul fiscal
// n'en dépend encore.
if (!invPeCols.includes('mode_detention')) {
db.exec("ALTER TABLE investissements_pe ADD COLUMN mode_detention TEXT NOT NULL DEFAULT 'direct'");
}
// Lien optionnel remboursement → deal PE (17/09/26) : une distribution (retour
// de capital, plus-value) perçue sur un deal PE est saisie comme un
// remboursement bonus_plateforme/interets_plateforme, comme demandé par
+31 -9
View File
@@ -106,12 +106,18 @@ function assertUniqueSouscription(body, categorie, excludeId) {
/**
* GET /api/frais-operations?investissement_id=123 | ?investissement_pe_id=456
* Sans filtre : tous les frais de l'utilisateur pour le workspace ACTIF (résolu via
* resolveActiveWorkspaceId, header X-Workspace-Id) — crowdlending ou PE selon le slug
* du workspace, enrichis de nom_projet/nom_deal + plateforme_nom/investisseur_nom (vue
* portefeuille, ex. page dédiée Frais/FraisPe). Le filtre par investissement_id/
* Sans filtre : tous les frais du détenteur actif (header X-Investisseur-Id) pour le
* workspace ACTIF (résolu via resolveActiveWorkspaceId, header X-Workspace-Id) — crowdlending
* ou PE selon le type du workspace, enrichis de nom_projet/nom_deal + plateforme_nom/
* investisseur_nom (vue portefeuille, ex. page dédiée Frais/FraisPe). ?scope=all agrège tous
* les détenteurs de l'utilisateur (vue "Famille et entreprises"), même mécanisme que GET
* /investissements-pe, /depots-retraits et /remboursements (19/09/26 — jusqu'ici cette route
* ignorait totalement le détenteur actif et renvoyait toujours tout le foyer, contrairement à
* ces trois autres routes ; bug remonté par Olivier : la page Frais ne filtrait jamais par
* détenteur, même un détenteur précis sélectionné). Le filtre par investissement_id/
* investissement_pe_id n'a pas besoin de cet enrichissement, le contexte est déjà connu
* côté client.
* côté client, et reste volontairement family-wide (propriété déjà vérifiée par
* assertOwnedInvestissement(Pe) sur le deal précis demandé).
*/
router.get('/', (req, res, next) => {
try {
@@ -138,6 +144,22 @@ router.get('/', (req, res, next) => {
} else {
const workspaceId = resolveActiveWorkspaceId(req);
const ws = db.prepare('SELECT type FROM workspaces WHERE id = ?').get(workspaceId);
const scopeAll = req.query.scope === 'all';
let invCond, invArgs;
if (scopeAll) {
invCond = 'inv.user_id = ?';
invArgs = [req.user.id];
} else {
const raw = req.header('X-Investisseur-Id');
const id = Number(raw);
if (!id) return res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' });
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(id, req.user.id);
if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' });
invCond = 'inv.id = ?';
invArgs = [id];
}
if (!ws || ws.type === 'crowdlending') {
rows = db.prepare(`
SELECT f.*, c.nom AS compte_nom, i.nom_projet, p.nom AS plateforme_nom, inv.nom AS investisseur_nom
@@ -146,9 +168,9 @@ router.get('/', (req, res, next) => {
JOIN investisseurs inv ON inv.id = i.investisseur_id
JOIN plateformes p ON p.id = i.plateforme_id
LEFT JOIN comptes c ON c.id = f.compte_id
WHERE inv.user_id = ?
WHERE ${invCond}
ORDER BY f.date_operation DESC, f.id DESC
`).all(req.user.id);
`).all(...invArgs);
} else {
rows = db.prepare(`
SELECT f.*, c.nom AS compte_nom, ipe.nom_deal, p.nom AS plateforme_nom, inv.nom AS investisseur_nom
@@ -157,9 +179,9 @@ router.get('/', (req, res, next) => {
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
JOIN plateformes p ON p.id = ipe.plateforme_id
LEFT JOIN comptes c ON c.id = f.compte_id
WHERE inv.user_id = ? AND ipe.workspace_id = ?
WHERE ${invCond} AND ipe.workspace_id = ?
ORDER BY f.date_operation DESC, f.id DESC
`).all(req.user.id, workspaceId);
`).all(...invArgs, workspaceId);
}
}
res.json(rows);
+26 -8
View File
@@ -44,6 +44,8 @@ const Schema = z.object({
statut: z.enum(['en_attente', 'valide', 'cloture', 'perte_definitive']).default('valide'),
reference: z.string().optional(),
notes: z.string().optional(),
// Mode de détention (19/09/26) — cf. migration mode_detention dans db/index.js.
mode_detention: z.enum(['direct', 'pea_pme', 'assurance_vie']).default('direct'),
});
const ValorisationSchema = z.object({
@@ -102,6 +104,11 @@ router.get('/', (req, res) => {
}
const workspaceId = resolveActiveWorkspaceId(req);
// Filtre optionnel par plateforme (19/09/26, page Plateformes PE) — mirroir du filtre
// plateforme_id déjà supporté par GET /depots-retraits et GET /remboursements.
const { plateforme_id } = req.query;
const platCond = plateforme_id ? 'AND ipe.plateforme_id = ?' : '';
const platArgs = plateforme_id ? [Number(plateforme_id)] : [];
const rows = db.prepare(`
SELECT ipe.*,
@@ -126,9 +133,9 @@ router.get('/', (req, res) => {
FROM investissements_pe ipe
JOIN plateformes p ON p.id = ipe.plateforme_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond} AND ipe.workspace_id = ?
WHERE ${invCond} AND ipe.workspace_id = ? ${platCond}
ORDER BY ipe.date_souscription DESC, ipe.id DESC
`).all(...invArgs, workspaceId);
`).all(...invArgs, workspaceId, ...platArgs);
const withTvpi = rows.map(r => ({
...r,
@@ -252,7 +259,15 @@ router.get('/mouvements-portefeuille', (req, res) => {
// (crowdlending) qui appelle cet endpoint. Pour depots_retraits en revanche (table partagée
// entre workspaces), il FAUT exclure explicitement le workspace appelant, pour ne pas
// dupliquer ce que le frontend a déjà récupéré via son propre GET /depots-retraits scopé.
//
// ?native=1 (19/09/26, page Plateformes PE) : bascule ce même endpoint pour un appel fait
// DEPUIS le workspace PE lui-même (plutôt que depuis crowdlending) — dans ce sens, les
// depots_retraits à renvoyer sont ceux DU workspace appelant (workspace_id = callerWorkspaceId),
// pas ceux d'un autre workspace. Les souscriptions/distributions/frais PE restent inchangées
// (déjà natives, sans filtre workspace_id — limite connue si plusieurs workspaces PE existent
// un jour, cf. commentaire ci-dessus, non résolue ici).
const callerWorkspaceId = resolveActiveWorkspaceId(req);
const native = req.query.native === '1';
const souscriptions = db.prepare(`
SELECT ipe.id AS deal_id, ipe.plateforme_id, p.nom AS plateforme_nom,
@@ -292,6 +307,7 @@ router.get('/mouvements-portefeuille', (req, res) => {
WHERE ${invCond}
`).all(...invArgs);
const drWorkspaceOp = native ? '=' : '!=';
const depots = db.prepare(`
SELECT dr.id AS dr_id, dr.plateforme_id, p.nom AS plateforme_nom,
plat_inv.nom AS plateforme_detenteur_nom,
@@ -301,7 +317,7 @@ router.get('/mouvements-portefeuille', (req, res) => {
JOIN plateformes p ON p.id = dr.plateforme_id
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
JOIN investisseurs inv ON inv.id = dr.investisseur_id
WHERE dr.workspace_id != ? AND ${invCondDr}
WHERE dr.workspace_id ${drWorkspaceOp} ? AND ${invCondDr}
`).all(callerWorkspaceId, ...invArgsDr);
const rows = [
@@ -342,13 +358,14 @@ router.post('/', requireInvestisseur, (req, res, next) => {
const r = db.prepare(`
INSERT INTO investissements_pe
(investisseur_id, plateforme_id, workspace_id, nom_deal, strategie, date_souscription,
duree_mois, montant_investi, capital_investissable, objectif, statut, reference, notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
duree_mois, montant_investi, capital_investissable, objectif, statut, reference, notes,
mode_detention)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
`).run(
investisseurId, body.plateforme_id, workspaceId, body.nom_deal, body.strategie || null,
body.date_souscription, body.duree_mois ?? null, body.montant_investi,
body.capital_investissable ?? null, body.objectif || null,
body.statut, body.reference || null, body.notes || null,
body.statut, body.reference || null, body.notes || null, body.mode_detention,
);
res.status(201).json({ id: r.lastInsertRowid, ...body });
} catch (e) { next(e); }
@@ -505,12 +522,13 @@ router.put('/:id', requireInvestisseur, (req, res, next) => {
UPDATE investissements_pe
SET investisseur_id=?, plateforme_id=?, nom_deal=?, strategie=?, date_souscription=?,
duree_mois=?, montant_investi=?, capital_investissable=?, objectif=?, statut=?,
reference=?, notes=?, updated_at=datetime('now')
reference=?, notes=?, mode_detention=?, updated_at=datetime('now')
WHERE id=?
`).run(
investisseurId, body.plateforme_id, body.nom_deal, body.strategie || null, body.date_souscription,
body.duree_mois ?? null, body.montant_investi, body.capital_investissable ?? null,
body.objectif || null, body.statut, body.reference || null, body.notes || null, id,
body.objectif || null, body.statut, body.reference || null, body.notes || null,
body.mode_detention, id,
);
res.json({ id, ...body });
} catch (e) { next(e); }
+12 -1
View File
@@ -32,6 +32,7 @@ import AdminIA from './pages/AdminIA.jsx';
import Aide from './pages/Aide.jsx';
import PlatformeProfile from './pages/PlatformeProfile.jsx';
import Plateformes from './pages/Plateformes.jsx';
import PlateformesPe from './pages/PlateformesPe.jsx';
import Notifications from './pages/Notifications.jsx';
import Communication from './pages/Communication.jsx';
import AssistantIA from './pages/AssistantIA.jsx';
@@ -153,6 +154,16 @@ function FraisRouter() {
return <FraisOnly><Frais /></FraisOnly>;
}
// Même principe (page Plateformes PE, 19/09/26, demande Olivier) : la page Plateformes
// bascule sur PlateformesPe.jsx pour tout workspace autre que crowdlending — plus de
// RequireWorkspace en dur sur cette route (elle était jusqu'ici réservée au crowdlending,
// cf. project_workspaces_transformation.md).
function PlateformesRouter() {
const { active } = useWorkspace() || {};
if (active && active.type !== 'crowdlending') return <PlateformesPe />;
return <Plateformes />;
}
export default function App() {
const [allowRegistration, setAllowRegistration] = useState(true);
@@ -197,7 +208,7 @@ export default function App() {
<Route path="/verify-email" element={<VerifyEmail />} />
<Route element={<Protected><Layout /></Protected>}>
<Route index element={<WorkspaceHome />} />
<Route path="plateformes" element={<RequireWorkspace type="crowdlending"><Plateformes /></RequireWorkspace>} />
<Route path="plateformes" element={<PlateformesRouter />} />
<Route path="depots-retraits" element={<DepotsRetraitsRouter />} />
<Route path="investissements" element={<InvestissementsRouter />} />
<Route path="investissements/:id" element={<InvestissementDetailRouter />} />
@@ -149,6 +149,14 @@ export default function InvestissementPeFormModal({
<label>Référence</label>
<input value={form.reference} onChange={e => setField('reference', e.target.value)} />
</div>
<div>
<label>Mode de détention</label>
<select value={form.mode_detention} onChange={e => setField('mode_detention', e.target.value)}>
<option value="direct">Direct (compte-titres)</option>
<option value="pea_pme">Enveloppe PEA / PEA-PME</option>
<option value="assurance_vie">Enveloppe Assurance-vie</option>
</select>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Détenteur *</label>
<select required value={form.investisseur_id} onChange={e => setField('investisseur_id', e.target.value)}>
+7 -14
View File
@@ -344,23 +344,16 @@ export default function Layout() {
<NavLink to="/" end title="Tableau de bord">
<NavIcon libFilename={navIcons.dashboard} Fallback={IconDashboard} /><span className="nav-label">Tableau de bord</span>
</NavLink>
{/* Plateformes reste propre au crowdlending pour l'instant (pas
d'équivalent PE — les plateformes PE se gèrent depuis Mes
plateformes, settings). Investissements, Dépôts/Retraits et
Remboursements (17/09/26) sont en revanche visibles quel que soit
le workspace actif — la route bascule automatiquement sur la
version adaptée (InvestissementsRouter / DepotsRetraitsRouter /
RemboursementsRouter, App.jsx). Chaque lien est conditionné
individuellement plutôt que regroupé dans un seul bloc
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. */}
{isCrowdlendingActive && (
{/* Plateformes (19/09/26) a désormais son équivalent PE (PlateformesPe.jsx,
PlateformesRouter dans App.jsx) — visible quel que soit le workspace actif, comme
Investissements, Dépôts/Retraits et Remboursements (17/09/26). Chaque lien reste
conditionné individuellement plutôt que regroupé dans un seul bloc
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>
+120
View File
@@ -0,0 +1,120 @@
import { useEffect, useRef, useState } from 'react';
const LOGOS_BASE = '/api/logos/';
/**
* Résout le fichier d'icône d'une plateforme, quel que soit le nom de champ utilisé par
* l'appelant (19/09/26, cf. project_workspaces_transformation.md — bug corrigé : la page
* Plateformes PE passait les lignes brutes de GET /plateformes, dont le champ réel est
* `icone_filename` et non `icon_filename`, et retombait donc sur `logo_filename`, un visuel
* plus large pensé pour un bandeau d'en-tête plutôt que pour une icône carrée) :
* - icon_filename : nom déjà normalisé par l'appelant (ex. platOptions de Plateformes.jsx,
* qui déduplique par plateforme et calcule ce champ lui-même) ;
* - icone_filename : champ réel de la table `plateformes` (icône carrée dédiée) ;
* - logo_filename : repli, pensé pour un bandeau large, mais mieux que rien si aucune icône
* carrée n'a été téléversée pour cette plateforme.
*/
function iconSrc(p) {
const filename = p?.icon_filename || p?.icone_filename || p?.logo_filename || null;
return filename ? `${LOGOS_BASE}${filename}` : null;
}
/**
* Sélecteur "Plateforme" — carte dégradée avec icône + nom, liste déroulante au clic.
* Composant partagé entre Plateformes.jsx (crowdlending) et PlateformesPe.jsx (Private Equity)
* pour éviter la dérive entre deux copies (même principe que MouvementsShared.jsx) : les deux
* pages passent leurs propres `platOptions` (formes différentes — platOptions dédupliqué par
* investissement côté crowdlending, lignes `plateformes` brutes filtrées par workspace côté PE)
* mais l'identité visuelle et la résolution d'icône restent identiques partout.
*/
export default function PlatSelector({ platOptions, selectedPlatName, onSelect }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
const selected = platOptions.find(p => p.nom === selectedPlatName) || platOptions[0];
const selectedIcon = iconSrc(selected);
return (
<div ref={ref} style={{ position: 'relative' }}>
<div
onClick={() => setOpen(v => !v)}
style={{
background: 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)',
borderRadius: 10, padding: '12px 16px',
boxShadow: open
? '0 6px 28px color-mix(in srgb, var(--primary) 45%, transparent)'
: '0 4px 20px color-mix(in srgb, var(--primary) 30%, transparent)',
cursor: 'pointer', userSelect: 'none', transition: 'box-shadow .15s',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ fontSize: 'var(--fs-xs)', textTransform: 'uppercase', letterSpacing: '.06em', color: 'rgba(255,255,255,0.7)', fontWeight: 500 }}>
Plateforme
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="rgba(255,255,255,0.7)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{selectedIcon && (
<img src={selectedIcon}
width={28} height={28}
style={{ objectFit: 'contain', borderRadius: 4, background: '#fff', padding: 2, flexShrink: 0 }}
alt="" />
)}
<span style={{ color: '#fff', fontSize: '1.1rem', fontWeight: 700, lineHeight: 1.2 }}>
{selected?.nom || '—'}
</span>
</div>
</div>
{open && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', left: 0, right: 0, zIndex: 200,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 10, boxShadow: '0 8px 28px rgba(0,0,0,0.15)', overflow: 'hidden',
maxHeight: 280, overflowY: 'auto',
}}>
{platOptions.map(p => {
const icon = iconSrc(p);
return (
<div
key={p.nom}
onClick={() => { onSelect(p.nom); setOpen(false); }}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 14px', cursor: 'pointer',
background: p.nom === selectedPlatName ? 'var(--surface-2)' : 'transparent',
fontWeight: p.nom === selectedPlatName ? 700 : 400,
fontSize: 'var(--fs-sm)',
borderBottom: '1px solid var(--border)',
transition: 'background .1s',
}}
onMouseEnter={e => { if (p.nom !== selectedPlatName) e.currentTarget.style.background = 'var(--surface-2)'; }}
onMouseLeave={e => { if (p.nom !== selectedPlatName) e.currentTarget.style.background = 'transparent'; }}
>
{icon ? (
<img src={icon}
width={20} height={20}
style={{ objectFit: 'contain', flexShrink: 0, opacity: 0.8 }}
alt="" />
) : (
<span style={{ width: 20, height: 20, borderRadius: 4, background: 'var(--surface-2)', flexShrink: 0, display: 'inline-block' }} />
)}
<span>{p.nom}</span>
</div>
);
})}
</div>
)}
</div>
);
}
+10 -8
View File
@@ -45,7 +45,7 @@ import { emptyMouvementForm } from '../components/MouvementsShared.jsx';
* investissements, sans corrections/réinvestissements/frais_operations.
*/
export default function DepotsRetraitsSimple() {
const { activeId: investisseurId, investisseurs } = useInvestisseur();
const { activeId: investisseurId, activeView, investisseurs } = useInvestisseur();
const { active: activeWorkspace } = useWorkspace() || {};
const location = useLocation();
const navigate = useNavigate();
@@ -83,10 +83,11 @@ export default function DepotsRetraitsSimple() {
const load = async () => {
setLoading(true);
try {
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
const [dr, pl, dash] = await Promise.all([
api.get('/depots-retraits'),
api.get('/depots-retraits', scopeParams),
api.get('/plateformes'),
api.get('/dashboard-pe'),
api.get('/dashboard-pe', scopeParams),
]);
setRows(dr);
setAllPlats(pl);
@@ -99,9 +100,10 @@ export default function DepotsRetraitsSimple() {
};
useEffect(() => {
if (investisseurId) load();
if (!investisseurId && activeView !== 'all') return;
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [investisseurId, activeWorkspace?.id]);
}, [investisseurId, activeView, activeWorkspace?.id]);
const years = useMemo(
() => [...new Set(rows.map(r => r.date_operation.slice(0, 4)))].sort().reverse(),
@@ -113,15 +115,15 @@ export default function DepotsRetraitsSimple() {
const prevYear = String(Number(platYear || new Date().getFullYear()) - 1);
useEffect(() => {
if (!platYear) return;
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
[platYear, prevYear].forEach(y => {
const key = `${y}-12-31`;
if (dashDataAsOf[key]) return;
api.get('/dashboard-pe', { asOf: key })
api.get('/dashboard-pe', { ...scopeParams, asOf: key })
.then(snap => setDashDataAsOf(prev => ({ ...prev, [key]: snap })))
.catch(() => {});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platYear, investisseurId, activeWorkspace?.id]);
}, [platYear, investisseurId, activeView, activeWorkspace?.id]);
const investisseurForPlat = (platId) => {
const plat = plats.find(p => String(p.id) === String(platId));
+1 -1
View File
@@ -62,7 +62,7 @@ export default function Frais() {
try {
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
const [f, inv] = await Promise.all([
api.get('/frais-operations'),
api.get('/frais-operations', scopeParams),
api.get('/investissements', scopeParams),
]);
setAllFraisOps(f);
+7 -4
View File
@@ -19,9 +19,12 @@ import { fmtEUR, fmtDate, today } from '../utils/format.js';
* (crowdlending) — même structure (graphiques, KPI, onglets Plateformes / Typologie /
* Investissements / Opérations), parité complète demandée explicitement par Olivier — avec
* les différences suivantes :
* - source des données : /investissements-pe (deals) au lieu de /investissements, et
* GET /frais-operations sans filtre se borne déjà au workspace ACTIF côté backend
* (resolveActiveWorkspaceId), donc renvoie naturellement les frais PE ici ;
* - source des données : /investissements-pe (deals) au lieu de /investissements. GET
* /frais-operations sans filtre se borne au workspace ACTIF côté backend
* (resolveActiveWorkspaceId) et, depuis le 19/09/26, au détenteur actif (header
* X-Investisseur-Id, ou agrégat si ?scope=all — même mécanisme que /investissements-pe,
* /depots-retraits, /remboursements ; correctif d'un bug signalé par Olivier, cette route
* ignorait jusqu'ici totalement le détenteur, côté PE comme crowdlending) ;
* - libellé "deal" / nom_deal au lieu de "projet" / nom_projet ;
* - gating d'accès via hasFraisPe (cf. AuthContext.jsx) plutôt que hasFrais, cf. FraisPeOnly
* dans App.jsx.
@@ -80,7 +83,7 @@ export default function FraisPe() {
try {
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
const [f, d] = await Promise.all([
api.get('/frais-operations'),
api.get('/frais-operations', scopeParams),
api.get('/investissements-pe', scopeParams),
]);
setAllFraisOps(f);
@@ -44,6 +44,12 @@ const STATUT_LABELS = {
perte_definitive: 'Perte définitive',
};
const MODE_DETENTION_LABELS = {
direct: 'Direct',
pea_pme: 'PEA / PEA-PME',
assurance_vie: 'Assurance-vie',
};
const FRAIS_MODE_LABELS = {
source: 'À la source',
portefeuille: 'Porte-monnaie',
@@ -628,6 +634,7 @@ export default function InvestissementPeDetail() {
{deal.objectif ? ` · Objectif : ${deal.objectif}` : ''}
{deal.reference ? ` · Réf. ${deal.reference}` : ''}
{deal.statut !== 'valide' ? ` · ${STATUT_LABELS[deal.statut] || deal.statut}` : ''}
{` · ${MODE_DETENTION_LABELS[deal.mode_detention] || 'Direct'}`}
</div>
{deal.notes && <div className="text-muted" style={{ marginBottom: 12, fontSize: 13 }}>{deal.notes}</div>}
+10 -5
View File
@@ -34,6 +34,7 @@ export const emptyDealForm = {
statut: 'valide',
reference: '',
notes: '',
mode_detention: 'direct',
// Étape "Frais" de la modale (chantier 17/09/26, demande Olivier — captures Fundora) : deux
// lignes indépendantes journalisées comme deux entrées frais_operations distinctes. Frais
// d'entrée → catégorie 'souscription' (unique par deal) ; Frais de gestion → catégorie
@@ -141,7 +142,7 @@ function TrendBadge({ current, prev, invert = false }) {
* historique à la date de coupure.
*/
export default function InvestissementsPe() {
const { activeId: investisseurId, investisseurs } = useInvestisseur();
const { activeId: investisseurId, activeView, investisseurs } = useInvestisseur();
const { active: activeWorkspace } = useWorkspace() || {};
const location = useLocation();
const navigate = useNavigate();
@@ -189,10 +190,11 @@ export default function InvestissementsPe() {
const load = async () => {
setLoading(true);
try {
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
const [inv, pl, val] = await Promise.all([
api.get('/investissements-pe'),
api.get('/investissements-pe', scopeParams),
api.get('/plateformes'),
api.get('/investissements-pe/valorisations'),
api.get('/investissements-pe/valorisations', scopeParams),
]);
setRows(inv);
setAllPlats(pl);
@@ -205,9 +207,10 @@ export default function InvestissementsPe() {
};
useEffect(() => {
if (investisseurId) load();
if (!investisseurId && activeView !== 'all') return;
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [investisseurId, activeWorkspace?.id]);
}, [investisseurId, activeView, activeWorkspace?.id]);
useEffect(() => {
if (!form.investisseur_id || !modalOpen) { setComptesInvestisseur([]); return; }
@@ -297,6 +300,7 @@ export default function InvestissementsPe() {
statut: r.statut,
reference: r.reference || '',
notes: r.notes || '',
mode_detention: r.mode_detention || 'direct',
frais_entree_mode_saisie: 'montant', frais_entree_valeur: '', frais_entree_mode_reglement: 'source', frais_entree_compte_id: '', frais_entree_id: null,
frais_gestion_mode_saisie: 'montant', frais_gestion_valeur: '', frais_gestion_mode_reglement: 'source', frais_gestion_compte_id: '', frais_gestion_id: null,
});
@@ -424,6 +428,7 @@ export default function InvestissementsPe() {
statut: form.statut,
reference: form.reference || undefined,
notes: form.notes || undefined,
mode_detention: form.mode_detention,
};
try {
let dealId = editingId;
+1 -89
View File
@@ -5,6 +5,7 @@ import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useUi } from '../context/UiContext.jsx';
import PageIcon from '../components/PageIcon.jsx';
import EmptyState from '../components/EmptyState.jsx';
import PlatSelector from '../components/PlatSelector.jsx';
import InvChart from '../components/InvChart.jsx';
import InvMensuelTable from '../components/InvMensuelTable.jsx';
import SuiviObjectifsCapital from '../components/SuiviObjectifsCapital.jsx';
@@ -24,7 +25,6 @@ import {
timestampSuffix, dlBlob, sortMouvements, computeSoldeIndicatifMap,
} from '../components/MouvementsShared.jsx';
const LOGOS_BASE = '/api/logos/';
/* ── TrendBadge ── */
function TrendBadge({ current, prev, invert = false }) {
@@ -53,94 +53,6 @@ function TrendBadge({ current, prev, invert = false }) {
/* ── Sélecteur Plateforme ── */
function PlatSelector({ platOptions, selectedPlatName, onSelect }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
const selected = platOptions.find(p => p.nom === selectedPlatName) || platOptions[0];
return (
<div ref={ref} style={{ position: 'relative' }}>
<div
onClick={() => setOpen(v => !v)}
style={{
background: 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)',
borderRadius: 10, padding: '12px 16px',
boxShadow: open
? '0 6px 28px color-mix(in srgb, var(--primary) 45%, transparent)'
: '0 4px 20px color-mix(in srgb, var(--primary) 30%, transparent)',
cursor: 'pointer', userSelect: 'none', transition: 'box-shadow .15s',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ fontSize: 'var(--fs-xs)', textTransform: 'uppercase', letterSpacing: '.06em', color: 'rgba(255,255,255,0.7)', fontWeight: 500 }}>
Plateforme
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="rgba(255,255,255,0.7)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{selected?.icon_filename && (
<img src={`${LOGOS_BASE}${selected.icon_filename}`}
width={28} height={28}
style={{ objectFit: 'contain', borderRadius: 4, background: '#fff', padding: 2, flexShrink: 0 }}
alt="" />
)}
<span style={{ color: '#fff', fontSize: '1.1rem', fontWeight: 700, lineHeight: 1.2 }}>
{selected?.nom || '—'}
</span>
</div>
</div>
{open && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', left: 0, right: 0, zIndex: 200,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 10, boxShadow: '0 8px 28px rgba(0,0,0,0.15)', overflow: 'hidden',
maxHeight: 280, overflowY: 'auto',
}}>
{platOptions.map(p => (
<div
key={p.nom}
onClick={() => { onSelect(p.nom); setOpen(false); }}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 14px', cursor: 'pointer',
background: p.nom === selectedPlatName ? 'var(--surface-2)' : 'transparent',
fontWeight: p.nom === selectedPlatName ? 700 : 400,
fontSize: 'var(--fs-sm)',
borderBottom: '1px solid var(--border)',
transition: 'background .1s',
}}
onMouseEnter={e => { if (p.nom !== selectedPlatName) e.currentTarget.style.background = 'var(--surface-2)'; }}
onMouseLeave={e => { if (p.nom !== selectedPlatName) e.currentTarget.style.background = 'transparent'; }}
>
{p.icon_filename ? (
<img src={`${LOGOS_BASE}${p.icon_filename}`}
width={20} height={20}
style={{ objectFit: 'contain', flexShrink: 0, opacity: 0.8 }}
alt="" />
) : (
<span style={{ width: 20, height: 20, borderRadius: 4, background: 'var(--surface-2)', flexShrink: 0, display: 'inline-block' }} />
)}
<span>{p.nom}</span>
</div>
))}
</div>
)}
</div>
);
}
/* ── Sélecteur Détenteur ── */
function DetenteurSelector({ detenteurOptions, selectedId, onSelect }) {
const [open, setOpen] = useState(false);
+646
View File
@@ -0,0 +1,646 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useWorkspace } from '../context/WorkspaceContext.jsx';
import Modal from '../components/Modal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx';
import EmptyState from '../components/EmptyState.jsx';
import PageIcon from '../components/PageIcon.jsx';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import { fmtEUR, fmtDate } from '../utils/format.js';
import {
sortMouvements, computeSoldeIndicatifMap, signedMontant, MOUV_TYPE_LABELS,
ExportDropdown, mouvToCSV, mouvToXLS, mouvToJSON, timestampSuffix, dlBlob,
} from '../components/MouvementsShared.jsx';
import PlatSelector from '../components/PlatSelector.jsx';
import InvChart from '../components/InvChart.jsx';
/**
* Page Plateformes — Private Equity (19/09/26, demande Olivier : "Nouvelle page dédiée mais
* similaire dans la présentation graphique [à Plateformes.jsx crowdlending] avec le maximum
* d'onglet réplicable. Il me faut aussi les mouvements du porte-monnaie").
*
* Contrairement à Plateformes.jsx (3362 lignes, très couplé au modèle crowdlending —
* échéancier, "Mois en cours", "Capital investi" amortissable), cette page est reconstruite
* plus sobrement autour des mêmes briques visuelles (kpi, dr-tabs, PlatSelector) mais avec
* uniquement les onglets qui ont un sens pour un deal PE : Investissements, Distributions,
* Dépôts/Retraits, Mouvements Porte-monnaie. Les onglets "Mois en cours" et "Capital investi"
* (échéancier/amortissement) n'existent pas côté PE (cf. project_workspaces_transformation.md)
* et ne sont donc pas répliqués.
*
* Onglet "Mouvements Porte-monnaie" : réutilise GET /investissements-pe/mouvements-portefeuille
* (déjà utilisé en sens inverse par Plateformes.jsx pour faire remonter les mouvements PE côté
* crowdlending) avec le nouveau paramètre ?native=1 (19/09/26) qui bascule le filtre
* depots_retraits sur le workspace PE actif au lieu de l'exclure. Limite connue, signalée à
* Olivier : si une plateforme est un jour partagée entre crowdlending ET Private Equity, cette
* page ne remonte pas encore les mouvements côté crowdlending de cette plateforme (l'inverse
* existe déjà côté Plateformes.jsx) — non bloquant aujourd'hui, ses plateformes PE (Fundora,
* Baltis) sont dédiées.
*/
const STATUT_LABELS = {
en_attente: 'En attente',
valide: 'Validé',
cloture: 'Clôturé',
perte_definitive: 'Perte définitive',
};
const MODE_DETENTION_LABELS = {
direct: 'Direct',
pea_pme: 'PEA / PEA-PME',
assurance_vie: 'Assurance-vie',
};
const fmtMultiple = (n) => (n != null ? `x${n.toFixed(2).replace('.', ',')}` : '—');
/* ── Sélecteur Période (copié de Plateformes.jsx — alimente InvChart via platYear, même
principe que le sélecteur Année du graphique "Capital investi" crowdlending) ── */
function AnneeSelector({ availableYears, selectedYear, onSelect }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
const options = [
{ value: '', label: 'Depuis le début' },
...[...availableYears].sort((a, b) => b - a).map(y => ({ value: String(y), label: String(y) })),
];
const displayLabel = selectedYear || 'Depuis le début';
const isGlobal = !selectedYear;
return (
<div ref={ref} style={{ position: 'relative' }}>
<div
onClick={() => setOpen(v => !v)}
style={{
background: 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)',
borderRadius: 10, padding: '12px 16px',
boxShadow: open
? '0 6px 28px color-mix(in srgb, var(--primary) 45%, transparent)'
: '0 4px 20px color-mix(in srgb, var(--primary) 30%, transparent)',
cursor: 'pointer', userSelect: 'none', transition: 'box-shadow .15s',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ fontSize: 'var(--fs-xs)', textTransform: 'uppercase', letterSpacing: '.06em', color: 'rgba(255,255,255,0.7)', fontWeight: 500 }}>
Période
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="rgba(255,255,255,0.7)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<span style={{ color: '#fff', fontSize: isGlobal ? '1rem' : '1.8rem', fontWeight: 700, lineHeight: 1.1 }}>
{displayLabel}
</span>
</div>
{open && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', left: 0, right: 0, zIndex: 200,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 10, boxShadow: '0 8px 28px rgba(0,0,0,0.15)', overflow: 'hidden',
}}>
{options.map(opt => (
<div
key={opt.value}
onClick={() => { onSelect(opt.value); setOpen(false); }}
style={{
padding: '10px 14px', cursor: 'pointer',
background: opt.value === selectedYear ? 'var(--surface-2)' : 'transparent',
fontWeight: opt.value === selectedYear ? 700 : 400,
fontSize: 'var(--fs-sm)',
borderBottom: '1px solid var(--border)',
transition: 'background .1s',
}}
onMouseEnter={e => { if (opt.value !== selectedYear) e.currentTarget.style.background = 'var(--surface-2)'; }}
onMouseLeave={e => { if (opt.value !== selectedYear) e.currentTarget.style.background = 'transparent'; }}
>
{opt.label}
</div>
))}
</div>
)}
</div>
);
}
export default function PlateformesPe() {
const { activeId, activeView, investisseurs } = useInvestisseur();
const { active: activeWorkspace } = useWorkspace() || {};
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [allPlats, setAllPlats] = useState([]);
const [deals, setDeals] = useState([]);
const [depots, setDepots] = useState([]);
const [remboursements, setRemboursements] = useState([]);
const [walletNative, setWalletNative] = useState([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState(null);
const [selectedYear, setSelectedYear] = useState(''); // Période du graphique Capital investi
const selectedPlatName = searchParams.get('plat') || null;
const setSelectedPlatName = (nom) => {
setSearchParams(p => { const n = new URLSearchParams(p); n.set('plat', nom); return n; }, { replace: true });
};
const activeTab = searchParams.get('tab') || 'investissements';
const setActiveTab = (tab) => {
setSearchParams(p => { const n = new URLSearchParams(p); n.set('tab', tab); return n; }, { replace: true });
};
const multiDetenteur = new Set(allPlats.map(p => p.investisseur_id)).size > 1;
// Plateformes affectées au workspace PE actif — même filtre que InvestissementsPe.jsx
// (p.workspaces posé par attachWorkspaces() côté backend, GET /plateformes n'est pas
// filtré serveur : la liste couvre volontairement tous les workspaces, cf. project_
// referentiel_plateformes.md).
// Plateformes affectées au workspace PE actif — indépendamment du détenteur, sert
// uniquement à distinguer "aucune plateforme configurée" de "aucun deal pour ce détenteur"
// dans les EmptyState ci-dessous (même distinction que Plateformes.jsx crowdlending, cf.
// plats vs platOptions).
const platsWorkspace = useMemo(
() => allPlats.filter(p => (p.workspaces || []).some(w => w.id === activeWorkspace?.id)),
[allPlats, activeWorkspace],
);
// Restreint en plus au détenteur courant (19/09/26, bug signalé par Olivier : "les pages
// plateforme et frais ne semblent pas filtrer par détenteur" — `deals` est déjà filtré par
// détenteur/scope=all via `load()` ci-dessous, mais la liste des plateformes elle-même était
// construite directement sur /plateformes, jamais restreinte au détenteur choisi, contrairement
// à Plateformes.jsx crowdlending dont platOptions est dérivé des lignes d'investissement déjà
// scopées — même principe repris ici pour rester cohérent avec le sélecteur crowdlending).
const plats = useMemo(() => {
const dealPlatIds = new Set(deals.map(d => d.plateforme_id));
return platsWorkspace.filter(p => dealPlatIds.has(p.id));
}, [platsWorkspace, deals]);
const load = async () => {
if (!activeId && activeView !== 'all') return;
setLoading(true);
setErr(null);
try {
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
const [p, d, dr, remb, wallet] = await Promise.all([
api.get('/plateformes'),
api.get('/investissements-pe', scopeParams),
api.get('/depots-retraits', scopeParams),
api.get('/remboursements', { ...scopeParams, type: 'interets_plateforme' }),
api.get('/investissements-pe/mouvements-portefeuille', { ...scopeParams, native: 1 }).catch(() => []),
]);
setAllPlats(p);
setDeals(d);
setDepots(dr);
setRemboursements(remb);
setWalletNative(Array.isArray(wallet) ? wallet : []);
} catch (e) { setErr(e.message); } finally { setLoading(false); }
};
useEffect(() => { load(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [activeId, activeView, activeWorkspace?.id]);
// Sélection automatique de la première plateforme dès que la liste est connue.
useEffect(() => {
if (!selectedPlatName && plats.length > 0) setSelectedPlatName(plats[0].nom);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [plats.length]);
const selectedPlat = plats.find(p => p.nom === selectedPlatName) || plats[0];
const platDeals = useMemo(
() => selectedPlat ? deals.filter(d => d.plateforme_id === selectedPlat.id) : [],
[deals, selectedPlat],
);
const platDealIds = useMemo(() => new Set(platDeals.map(d => d.id)), [platDeals]);
const platDepots = useMemo(
() => selectedPlat ? depots.filter(r => r.plateforme_id === selectedPlat.id) : [],
[depots, selectedPlat],
);
const platRembs = useMemo(
() => platDeals.length ? remboursements.filter(r => platDealIds.has(r.investissement_pe_id)) : [],
[remboursements, platDealIds, platDeals.length],
);
// Alias investissement_pe_id → investissement_id : InvChart.jsx (composant partagé avec le
// crowdlending, cf. project_workspaces_transformation.md) est déjà générique sur les champs
// communs aux deux modèles (id / date_souscription / montant_investi identiques dans
// investissements et investissements_pe) mais attend investissement_id pour rattacher un
// remboursement à sa ligne — un simple alias plutôt qu'une fourche du composant partagé.
const platRembsForChart = useMemo(
() => platRembs.map(r => ({ ...r, investissement_id: r.investissement_pe_id })),
[platRembs],
);
// Années disponibles pour le sélecteur "Période" du graphique — même principe que
// Plateformes.jsx (années de date_souscription des lignes du scope courant).
const availableYears = useMemo(() => {
const set = new Set(platDeals.map(d => d.date_souscription?.slice(0, 4)).filter(Boolean));
return [...set].map(Number).sort((a, b) => b - a);
}, [platDeals]);
const platWallet = useMemo(
() => selectedPlat ? walletNative.filter(r => r.plateforme_id === selectedPlat.id).map(r => ({ ...r, _investType: 'pe' })) : [],
[walletNative, selectedPlat],
);
// KPIs de la plateforme sélectionnée — mêmes formules que le bloc "totals" de
// DashboardPe.jsx / backend/src/routes/v1/dashboard.js (bloc investissements_pe),
// appliquées ici aux seuls deals de cette plateforme plutôt qu'à tout le portefeuille.
const kpis = useMemo(() => {
const investi = platDeals.reduce((s, d) => s + (d.montant_investi || 0), 0);
const nav = platDeals.reduce((s, d) => s + (d.derniere_valorisation ?? d.montant_investi ?? 0), 0);
const distributions = platDeals.reduce((s, d) => s + (d.distributions_cumulees || 0), 0);
const tvpi = investi > 0 ? Math.round(((nav + distributions) / investi) * 100) / 100 : null;
return { investi, nav, distributions, tvpi };
}, [platDeals]);
const walletSorted = useMemo(() => sortMouvements(platWallet), [platWallet]);
const soldeById = useMemo(() => computeSoldeIndicatifMap(platWallet), [platWallet]);
const soldeIndicatif = walletSorted.length ? (soldeById.get(walletSorted[0].id) ?? 0) : 0;
/* ── Modale Dépôt/Retrait (même Schema que backend/src/routes/depotsRetraits.js) ── */
const emptyDrForm = { investisseur_id: '', plateforme_id: '', date_operation: '', type: 'depot', montant: '', libelle: '', reference: '', notes: '' };
const [drModalOpen, setDrModalOpen] = useState(false);
const [drEditingId, setDrEditingId] = useState(null);
const [drForm, setDrForm] = useState(emptyDrForm);
const [drErr, setDrErr] = useState(null);
const [drDeleteTarget, setDrDeleteTarget] = useState(null);
const { run: runDrSubmit, pending: drSubmitting } = useAsyncAction();
const today = () => new Date().toISOString().slice(0, 10);
const openDrAdd = () => {
setDrEditingId(null);
setDrForm({ ...emptyDrForm, plateforme_id: selectedPlat?.id || '', investisseur_id: selectedPlat?.investisseur_id ? String(selectedPlat.investisseur_id) : '', date_operation: today() });
setDrErr(null);
setDrModalOpen(true);
};
const openDrEdit = (r) => {
setDrEditingId(r.id);
setDrForm({
investisseur_id: String(r.investisseur_id || ''),
plateforme_id: String(r.plateforme_id || ''),
date_operation: r.date_operation,
type: r.type,
montant: String(r.montant ?? ''),
libelle: r.libelle || '',
reference: r.reference || '',
notes: r.notes || '',
});
setDrErr(null);
setDrModalOpen(true);
};
const closeDr = () => { setDrModalOpen(false); setDrEditingId(null); setDrForm(emptyDrForm); setDrErr(null); };
const submitDr = async (e) => {
e?.preventDefault?.(); setDrErr(null);
const payload = {
investisseur_id: Number(drForm.investisseur_id) || undefined,
plateforme_id: Number(drForm.plateforme_id),
date_operation: drForm.date_operation,
type: drForm.type,
montant: Number(drForm.montant),
libelle: drForm.libelle || undefined,
reference: drForm.reference || undefined,
notes: drForm.notes || undefined,
};
try {
if (drEditingId) await api.put(`/depots-retraits/${drEditingId}`, payload);
else await api.post('/depots-retraits', payload);
closeDr();
await load();
} catch (e) { setDrErr(e.message); }
};
const onDeleteDr = async () => {
if (!drDeleteTarget) return;
try {
await api.del(`/depots-retraits/${drDeleteTarget.id}`);
setDrDeleteTarget(null);
await load();
} catch (e) { setErr(e.message); }
};
/* ── Export de l'onglet Mouvements Porte-monnaie ── */
const exportWallet = (format) => {
const rows = walletSorted.map(r => ({
...r,
solde_indicatif: soldeById.get(r.id),
type_label: MOUV_TYPE_LABELS[r.type] || r.type,
}));
const suffix = timestampSuffix();
if (format === 'csv') dlBlob(mouvToCSV(rows), `mouvements_${selectedPlat?.nom || 'pe'}_${suffix}.csv`, 'text/csv;charset=utf-8;');
else if (format === 'xls') dlBlob(mouvToXLS(rows, multiDetenteur), `mouvements_${selectedPlat?.nom || 'pe'}_${suffix}.xls`, 'application/vnd.ms-excel');
else dlBlob(mouvToJSON(rows), `mouvements_${selectedPlat?.nom || 'pe'}_${suffix}.json`, 'application/json');
};
if (!loading && platsWorkspace.length === 0) return (
<>
<div className="topbar"><h2><PageIcon name="plateforme" />Plateformes</h2></div>
<EmptyState
title="Aucune plateforme dans cet espace de travail"
message={`Ajoutez d'abord une plateforme à ${activeWorkspace?.libelle_menu || activeWorkspace?.nom || 'ce workspace'} depuis Mes plateformes.`}
cta="Configurer mes plateformes"
to="/settings?section=plateformes&openAdd=1"
/>
</>
);
if (!loading && plats.length === 0) return (
<>
<div className="topbar"><h2><PageIcon name="plateforme" />Plateformes</h2></div>
<EmptyState
icon="📊"
title="Aucun deal pour le moment"
message="Vos plateformes sont configurées, mais ce détenteur n'a encore aucun deal enregistré dessus. Ajoutez un investissement, ou changez de détenteur (menu en haut à droite), pour voir apparaître les données."
cta="Ajouter un investissement"
to="/investissements?new=1"
/>
</>
);
return (
<>
<div className="topbar">
<h2><PageIcon name="plateforme" />Plateformes</h2>
</div>
{err && <div className="error" style={{ margin: '0 0 16px' }}>{err}</div>}
{selectedPlat && (
<>
{/* ── Graphique + sélecteurs (19/09/26, demande Olivier : "graph sur le capital
investi" — même format que le graphique Capital investi de Plateformes.jsx
crowdlending, InvChart.jsx réutilisé tel quel cf. commentaire platRembsForChart
ci-dessus) ── */}
<div className="charts-row">
<InvChart
rows={platDeals}
remboursements={platRembsForChart}
reinvestissements={[]}
platYear={selectedYear}
/>
<div className="dist-chart-wrap" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<PlatSelector
platOptions={plats}
selectedPlatName={selectedPlat?.nom}
onSelect={setSelectedPlatName}
/>
<AnneeSelector
availableYears={availableYears}
selectedYear={selectedYear}
onSelect={setSelectedYear}
/>
</div>
</div>
{/* ── KPIs ── */}
<div className="dr-kpi-row">
<div className="kpi">
<div className="label">Capital investi</div>
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(kpis.investi)}</div>
</div>
<div className="kpi">
<div className="label">Valorisation nette (NAV)</div>
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(kpis.nav)}</div>
</div>
<div className="kpi">
<div className="label">Distributions cumulées</div>
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(kpis.distributions)}</div>
</div>
<div className="kpi" title="TVPI = (NAV + distributions cumulées) / capital investi">
<div className="label">TVPI</div>
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4 }}>{fmtMultiple(kpis.tvpi)}</div>
</div>
</div>
{/* ── Onglets ── */}
<div className="dr-tabs">
<button className={`dr-tab${activeTab === 'investissements' ? ' active' : ''}`}
onClick={() => setActiveTab('investissements')}>Investissements</button>
<button className={`dr-tab${activeTab === 'distributions' ? ' active' : ''}`}
onClick={() => setActiveTab('distributions')}>Distributions</button>
<button className={`dr-tab${activeTab === 'depots-retraits' ? ' active' : ''}`}
onClick={() => setActiveTab('depots-retraits')}>Dépôts / Retraits</button>
<button className={`dr-tab${activeTab === 'mouvements-pm' ? ' active' : ''}`}
onClick={() => setActiveTab('mouvements-pm')}>Mouvements Porte-monnaie</button>
</div>
{/* ====== ONGLET INVESTISSEMENTS ====== */}
{activeTab === 'investissements' && (
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Investissements sur {selectedPlat.nom}</h3>
<button className="primary" onClick={() => navigate('/investissements?new=1')}>+ Nouveau deal</button>
</div>
<table>
<thead>
<tr>
<th>Deal</th><th>Souscrit le</th><th className="num">Montant investi</th>
<th className="num">Dernière valorisation</th><th className="num">TVPI</th>
<th>Statut</th><th>Détention</th>
</tr>
</thead>
<tbody>
{platDeals.length === 0 && (
<tr><td colSpan={7} className="text-muted" style={{ textAlign: 'center' }}>Aucun deal sur cette plateforme</td></tr>
)}
{platDeals.map(d => (
<tr key={d.id} className="dr-row" style={{ cursor: 'pointer' }}
onClick={() => navigate(`/investissements/${d.id}`)}>
<td>{d.nom_deal}</td>
<td>{fmtDate(d.date_souscription)}</td>
<td className="num">{fmtEUR(d.montant_investi)}</td>
<td className="num">{d.derniere_valorisation != null ? fmtEUR(d.derniere_valorisation) : '—'}</td>
<td className="num">{fmtMultiple(d.tvpi)}</td>
<td>{STATUT_LABELS[d.statut] || d.statut}</td>
<td className="text-muted">{MODE_DETENTION_LABELS[d.mode_detention] || 'Direct'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* ====== ONGLET DISTRIBUTIONS ====== */}
{activeTab === 'distributions' && (
<div className="card">
<h3 style={{ margin: '0 0 14px' }}>Distributions perçues sur {selectedPlat.nom}</h3>
<p className="text-muted" style={{ fontSize: 13, marginTop: 0 }}>
Les distributions se saisissent depuis la fiche de chaque deal, page Investissements
(comme un remboursement) — cette vue est un récapitulatif en lecture seule.
</p>
<table>
<thead>
<tr><th>Date</th><th>Deal</th><th>Nature</th><th className="num">Montant net perçu</th></tr>
</thead>
<tbody>
{platRembs.length === 0 && (
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center' }}>Aucune distribution enregistrée</td></tr>
)}
{[...platRembs].sort((a, b) => b.date_remb.localeCompare(a.date_remb) || b.id - a.id).map(r => (
<tr key={r.id}>
<td>{fmtDate(r.date_remb)}</td>
<td>{deals.find(d => d.id === r.investissement_pe_id)?.nom_deal || '—'}</td>
<td className="text-muted">{r.distribution_nature === 'interet_non_deploye' ? 'Intérêt sur capital non déployé' : 'Restitution de capital'}</td>
<td className="num">{fmtEUR(r.net_recu)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* ====== ONGLET DÉPÔTS / RETRAITS ====== */}
{activeTab === 'depots-retraits' && (
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Dépôts / Retraits — {selectedPlat.nom}</h3>
<button className="primary" onClick={openDrAdd}>+ Nouveau dépôt/retrait</button>
</div>
<table>
<thead>
<tr><th>Date</th><th>Type</th><th className="num">Montant</th><th>Libellé</th><th style={{ width: 40 }}></th></tr>
</thead>
<tbody>
{platDepots.length === 0 && (
<tr><td colSpan={5} className="text-muted" style={{ textAlign: 'center' }}>Aucun mouvement</td></tr>
)}
{[...platDepots].sort((a, b) => b.date_operation.localeCompare(a.date_operation) || b.id - a.id).map(r => (
<tr key={r.id} className="dr-row" style={{ cursor: 'pointer' }} onClick={() => openDrEdit(r)}>
<td>{fmtDate(r.date_operation)}</td>
<td>{r.type === 'depot' ? 'Dépôt' : 'Retrait'}</td>
<td className="num">{fmtEUR(r.montant)}</td>
<td className="text-muted">{r.libelle || '—'}</td>
<td onClick={e => e.stopPropagation()}>
<button className="icon-btn" title="Supprimer" onClick={() => setDrDeleteTarget(r)}>✕</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* ====== ONGLET MOUVEMENTS PORTE-MONNAIE ====== */}
{activeTab === 'mouvements-pm' && (
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Mouvements Porte-monnaie — {selectedPlat.nom}</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<span className="text-muted" style={{ fontSize: 13 }}>Solde indicatif : <strong>{fmtEUR(soldeIndicatif)}</strong></span>
<ExportDropdown disabled={walletSorted.length === 0}
onCSV={() => exportWallet('csv')} onXLS={() => exportWallet('xls')} onJSON={() => exportWallet('json')} />
</div>
</div>
<p className="text-muted" style={{ fontSize: 13, marginTop: 0 }}>
Journal unifié des souscriptions, distributions, frais et dépôts/retraits de cette
plateforme — solde indicatif, pas une source de vérité chiffrée (mêmes principes que
l'onglet équivalent côté crowdlending). Chaque ligne s'édite depuis son onglet d'origine.
</p>
<table>
<thead>
<tr><th>Date</th><th>Type</th><th>Libellé</th><th className="num">Montant</th><th className="num">Solde indicatif</th></tr>
</thead>
<tbody>
{walletSorted.length === 0 && (
<tr><td colSpan={5} className="text-muted" style={{ textAlign: 'center' }}>Aucun mouvement</td></tr>
)}
{walletSorted.map(r => {
// signedMontant() (MouvementsShared.jsx) reste la seule source de vérité pour ce
// calcul, partagée avec l'onglet équivalent côté crowdlending — ne jamais le
// réimplémenter ici, cf. project_mouvements_shared_plateformes_tab.md.
const signed = signedMontant(r);
return (
<tr key={r.id}>
<td>{fmtDate(r.date_operation)}</td>
<td>{MOUV_TYPE_LABELS[r.type] || r.type}</td>
<td className="text-muted">{r.libelle || '—'}</td>
<td className="num" style={{ color: signed < 0 ? 'var(--danger, #dc2626)' : signed > 0 ? 'var(--success, #16a34a)' : undefined }}>
{signed > 0 ? '+' : ''}{fmtEUR(signed)}
</td>
<td className="num text-muted">{fmtEUR(soldeById.get(r.id) ?? 0)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</>
)}
<Modal
open={drModalOpen}
title={drEditingId ? 'Modifier le mouvement' : 'Nouveau dépôt/retrait'}
onClose={closeDr}
busy={drSubmitting}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<div>
{drEditingId && (
<button className="danger" disabled={drSubmitting}
onClick={() => { const target = { id: drEditingId }; closeDr(); setDrDeleteTarget(target); }}>
Supprimer
</button>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button disabled={drSubmitting} onClick={closeDr}>Annuler</button>
<button className="primary" disabled={drSubmitting} onClick={() => runDrSubmit(submitDr)}>
{drSubmitting ? (drEditingId ? 'Enregistrement…' : 'Création…') : (drEditingId ? 'Enregistrer' : 'Créer')}
</button>
</div>
</div>
}
>
<form onSubmit={e => runDrSubmit(submitDr, e)}>
{drErr && <div className="error">{drErr}</div>}
<div className="form-grid">
<div>
<label>Date *</label>
<input type="date" required value={drForm.date_operation} onChange={e => setDrForm({ ...drForm, date_operation: e.target.value })} />
</div>
<div>
<label>Type *</label>
<select required value={drForm.type} onChange={e => setDrForm({ ...drForm, type: e.target.value })}>
<option value="depot">Dépôt</option>
<option value="retrait">Retrait</option>
</select>
</div>
<div>
<label>Montant (€) *</label>
<input type="number" step="0.01" min="0" required value={drForm.montant} onChange={e => setDrForm({ ...drForm, montant: e.target.value })} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Libellé</label>
<input value={drForm.libelle} onChange={e => setDrForm({ ...drForm, libelle: e.target.value })} placeholder="Ex. Appel de fonds" />
</div>
<div>
<label>Référence</label>
<input value={drForm.reference} onChange={e => setDrForm({ ...drForm, reference: e.target.value })} />
</div>
{multiDetenteur && (
<div style={{ gridColumn: '1 / -1' }}>
<label>Détenteur *</label>
<select required value={drForm.investisseur_id} onChange={e => setDrForm({ ...drForm, investisseur_id: e.target.value })}>
<option value="">—</option>
{investisseurs.map(i => <option key={i.id} value={i.id}>{i.nom}{i.is_principal ? ' (principal)' : ''}</option>)}
</select>
</div>
)}
<div style={{ gridColumn: '1 / -1' }}>
<label>Notes</label>
<input value={drForm.notes} onChange={e => setDrForm({ ...drForm, notes: e.target.value })} />
</div>
</div>
</form>
</Modal>
<ConfirmModal
open={!!drDeleteTarget}
title="Supprimer ce mouvement ?"
message="Cette action est irréversible."
confirmLabel="Supprimer"
onConfirm={onDeleteDr}
onCancel={() => setDrDeleteTarget(null)}
/>
</>
);
}
+7 -5
View File
@@ -209,7 +209,7 @@ const emptyRembForm = {
* X-Workspace-Id (cf. workspaceScope.js, Étape 2a/2c du 17/09/26).
*/
export default function RemboursementsSimple() {
const { activeId: investisseurId } = useInvestisseur();
const { activeId: investisseurId, activeView } = useInvestisseur();
const { active: activeWorkspace } = useWorkspace() || {};
const location = useLocation();
const navigate = useNavigate();
@@ -258,10 +258,11 @@ export default function RemboursementsSimple() {
const load = async () => {
setLoading(true);
try {
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
const [rb, pl, dl, pfu] = await Promise.all([
api.get('/remboursements'),
api.get('/remboursements', scopeParams),
api.get('/plateformes'),
api.get('/investissements-pe'),
api.get('/investissements-pe', scopeParams),
api.get('/pfu'),
]);
setRows(rb);
@@ -276,9 +277,10 @@ export default function RemboursementsSimple() {
};
useEffect(() => {
if (investisseurId) load();
if (!investisseurId && activeView !== 'all') return;
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [investisseurId, activeWorkspace?.id]);
}, [investisseurId, activeView, activeWorkspace?.id]);
const investisseurForPlat = (platId) => {
const plat = plats.find(p => String(p.id) === String(platId));