This commit is contained in:
ocroguennec committed 2026-09-19 14:37:02 +02:00
1 parent 6bba34396c
commit 1d5737703b
9 files changed
+347 -45

No files matched your search

+1
View File
@@ -559,6 +559,7 @@ export default function FraisPe() {
investissementOptions={investissementOptions}
confirmingDelete={confirmingDelete}
setConfirmingDelete={setConfirmingDelete}
pe
/>
</>
);
@@ -1015,6 +1015,7 @@ export default function InvestissementPeDetail() {
submitting={fraisSubmitting}
comptesInvestisseur={comptesInvestisseur}
locked
pe
investissementOptions={[]}
confirmingDelete={fraisConfirmingDelete}
setConfirmingDelete={setFraisConfirmingDelete}
+103 -27
View File
@@ -306,6 +306,12 @@ export default function Plateformes() {
const [allDepots, setAllDepots] = useState([]);
const [allCorrections, setAllCorrections] = useState([]);
const [allFraisOps, setAllFraisOps] = useState([]); // frais_operations, tous investisseurs (filtré via mvInvById)
// Mouvements porte-monnaie PE (souscriptions/distributions/frais) vivant hors du workspace
// crowdlending actif — cf. GET /investissements-pe/mouvements-portefeuille. Une plateforme
// (ex. Fundora) peut faire du crowdlending ET du PE avec un porte-monnaie physique unique ;
// sans ça, le solde indicatif de l'onglet "Mouvements Porte-monnaie" ignorait tout l'argent
// côté PE (bug signalé par Olivier le 19/09/26).
const [allPeWalletMouvements, setAllPeWalletMouvements] = useState([]);
const [loading, setLoading] = useState(false);
// Snapshots backend du solde porte-monnaie par plateforme, clé 'live' ou 'YYYY-12-31'
// (GET /dashboard[?asOf=]) — remplace le recalcul client historique de
@@ -377,7 +383,7 @@ export default function Plateformes() {
setLoading(true);
try {
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
const [rows, p, rembs, reinvests, depots, corrections, simulRembs, pfu, fraisOps] = await Promise.all([
const [rows, p, rembs, reinvests, depots, corrections, simulRembs, pfu, fraisOps, peWalletMouvements] = await Promise.all([
api.get('/investissements', scopeParams),
api.get('/plateformes'),
api.get('/remboursements', scopeParams),
@@ -389,6 +395,10 @@ export default function Plateformes() {
// /frais-operations sans investissement_id ne connaît lui aussi que l'utilisateur —
// même pattern que /reinvestissements, filtré côté client via mvInvById.
api.get('/frais-operations').catch(() => []),
// Toujours hors scopeParams (scope=all/X-Investisseur-Id géré côté backend comme les
// autres endpoints ci-dessus) mais jamais borné au workspace actif, volontairement —
// cf. commentaire de la route.
api.get('/investissements-pe/mouvements-portefeuille', scopeParams).catch(() => []),
]);
setAllRows(rows);
setPlats(p);
@@ -399,6 +409,7 @@ export default function Plateformes() {
setAllSimulRembs(Array.isArray(simulRembs) ? simulRembs : []);
setPfuRates(Array.isArray(pfu) ? pfu : []);
setAllFraisOps(Array.isArray(fraisOps) ? fraisOps : []);
setAllPeWalletMouvements(Array.isArray(peWalletMouvements) ? peWalletMouvements : []);
} finally { setLoading(false); setDrillRefreshKey(k => k + 1); }
};
@@ -1078,7 +1089,7 @@ export default function Plateformes() {
allReinvests, allCorrections, plats). Toute évolution de la logique de
décomposition des mouvements doit être répercutée dans les deux fichiers.
══════════════════════════════════════════════════════════════════════ */
const [mvFilter, setMvFilter] = useState({ type: '', plateforme_ids: [], projet: '', year: '', month: '' });
const [mvFilter, setMvFilter] = useState({ type: '', investType: '', plateforme_ids: [], projet: '', year: '', month: '' });
const [mvListFocused, setMvListFocused] = useState(false);
const [mvModalOpen, setMvModalOpen] = useState(false);
const [mvEditingId, setMvEditingId] = useState(null);
@@ -1327,6 +1338,32 @@ export default function Plateformes() {
return true;
}), [mvNormalizedFraisMouvements, mvFilter.plateforme_ids, mvFilter.year, mvFilter.month]);
// Mouvements PE (19/09/26, demande Olivier — porte-monnaie unifié) : déjà mis en forme côté
// backend (GET /investissements-pe/mouvements-portefeuille, type/montant/date_operation/
// plateforme_id/libelle prêts à l'emploi), on ajoute juste ici _investType (pour le filtre et
// le badge) et `source` (même convention que les lignes crowdlending, pour l'export).
// Depuis le 19/09/26, allPeWalletMouvements inclut aussi des lignes type='depot'/'retrait'
// (dépôts manuels enregistrés côté workspace PE, ex. les 4 x 800 € de Fundora) : on leur
// reconstitue un `source` cohérent avec les dépôts crowdlending (mvRows, via raw_source =
// valeur brute de depots_retraits.source) pour que le badge "AUTO" et l'export CSV/XLS
// restent corrects.
const mvNormalizedPeMouvements = useMemo(() => allPeWalletMouvements.map(r => ({
...r,
_investType: 'pe',
source: r.type === 'souscription' ? 'souscription'
: r.type === 'frais' ? 'frais_operation'
: (r.type === 'depot' || r.type === 'retrait') ? (r.raw_source || 'manuel')
: 'remboursement',
})), [allPeWalletMouvements]);
const mvFilteredPeMouvements = useMemo(() => mvNormalizedPeMouvements.filter(r => {
if (mvFilter.plateforme_ids.length > 0 && !mvFilter.plateforme_ids.includes(String(r.plateforme_id))) return false;
const [y, m] = r.date_operation.split('-');
if (mvFilter.year && y !== mvFilter.year) return false;
if (mvFilter.month && m !== mvFilter.month.padStart(2,'0')) return false;
return true;
}), [mvNormalizedPeMouvements, mvFilter.plateforme_ids, mvFilter.year, mvFilter.month]);
// Projets disponibles pour le filtre "Projet" — dépend de la plateforme (verrouillée) mais
// pas des autres filtres (type/année/mois), pour ne pas faire disparaître d'options en cours
// d'affinage. Inclut les remboursements/souscriptions/réinvestissements ainsi que les
@@ -1338,46 +1375,55 @@ export default function Plateformes() {
for (const r of mvNormalizedRembMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle);
for (const r of mvNormalizedSouscriptionMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle);
for (const r of mvNormalizedFraisMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle);
for (const r of mvNormalizedPeMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle);
for (const d of allDepots) {
if (!inScope(d.plateforme_id) || !d.remboursement_id) continue;
const p = mvRembProjetById.get(d.remboursement_id);
if (p) set.add(p);
}
return [...set].sort((a, b) => a.localeCompare(b, 'fr'));
}, [mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements, mvNormalizedFraisMouvements, allDepots, mvRembProjetById, mvFilter.plateforme_ids]);
}, [mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements, mvNormalizedFraisMouvements, mvNormalizedPeMouvements, allDepots, mvRembProjetById, mvFilter.plateforme_ids]);
// Solde indicatif du porte-monnaie après chaque mouvement — calculé sur l'historique
// COMPLET et NON FILTRÉ (allDepots/mvNormalizedCorrections/mvNormalizedRembMouvements/
// mvNormalizedSouscriptionMouvements), pas sur les variantes déjà filtrées année/mois/
// type/projet — sinon le cumul démarrerait arbitrairement au milieu de l'historique.
// mvNormalizedSouscriptionMouvements/mvNormalizedFraisMouvements/mvNormalizedPeMouvements),
// pas sur les variantes déjà filtrées année/mois/type/projet/investType — sinon le cumul
// démarrerait arbitrairement au milieu de l'historique. mvNormalizedPeMouvements est inclus
// ici (19/09/26, demande Olivier) même s'il n'a pas d'onglet dédié pour l'afficher seul : sur
// une plateforme partagée, ces lignes PE font partie du MÊME porte-monnaie physique, donc du
// même cumul, qu'on les affiche ou non selon le filtre "Type d'investissement" du moment.
// Purement indicatif (affichage/export) — ne remplace pas computeSoldePortefeuille.
// Voir [[project_mouvements_solde_indicatif]].
const mvSoldeIndicatifByRowId = useMemo(() => computeSoldeIndicatifMap([
...allDepots, ...mvNormalizedCorrections, ...mvNormalizedRembMouvements, ...mvNormalizedSouscriptionMouvements,
...mvNormalizedFraisMouvements,
]), [allDepots, mvNormalizedCorrections, mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements, mvNormalizedFraisMouvements]);
...mvNormalizedFraisMouvements, ...mvNormalizedPeMouvements,
]), [allDepots, mvNormalizedCorrections, mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements, mvNormalizedFraisMouvements, mvNormalizedPeMouvements]);
const mvTableRows = useMemo(() => {
const showDR = !mvFilter.type || mvFilter.type === 'depot' || mvFilter.type === 'retrait';
const showCorr = !mvFilter.type || mvFilter.type === 'correction';
const drPart = showDR
? mvRows.map(r => ({ ...r, _projet: r.remboursement_id ? (mvRembProjetById.get(r.remboursement_id) || '') : '' }))
const showCl = mvFilter.investType !== 'pe';
const showPe = mvFilter.investType !== 'crowdlending';
const drPart = showDR && showCl
? mvRows.map(r => ({ ...r, _projet: r.remboursement_id ? (mvRembProjetById.get(r.remboursement_id) || '') : '', _investType: 'crowdlending' }))
: [];
const corrPart = showCorr ? mvFilteredCorrections.map(c => ({ ...c, _projet: '' })) : [];
const rembPart = mvFilteredRembMouvements
const corrPart = showCorr && showCl ? mvFilteredCorrections.map(c => ({ ...c, _projet: '', _investType: 'crowdlending' })) : [];
const rembPart = showCl ? mvFilteredRembMouvements
.filter(r => !mvFilter.type || r.type === mvFilter.type)
.map(r => ({ ...r, _projet: r.libelle || '' }));
const subPart = mvFilteredSouscriptionMouvements
.map(r => ({ ...r, _projet: r.libelle || '', _investType: 'crowdlending' })) : [];
const subPart = showCl ? mvFilteredSouscriptionMouvements
.filter(r => !mvFilter.type || r.type === mvFilter.type)
.map(r => ({ ...r, _projet: r.libelle || '' }));
const fraisPart = mvFilteredFraisMouvements
.map(r => ({ ...r, _projet: r.libelle || '', _investType: 'crowdlending' })) : [];
const fraisPart = showCl ? mvFilteredFraisMouvements
.filter(r => !mvFilter.type || r.type === mvFilter.type)
.map(r => ({ ...r, _projet: r.libelle || '' }));
.map(r => ({ ...r, _projet: r.libelle || '', _investType: 'crowdlending' })) : [];
const pePart = showPe ? mvFilteredPeMouvements
.filter(r => !mvFilter.type || r.type === mvFilter.type)
.map(r => ({ ...r, _projet: r.libelle || '' })) : [];
return sortMouvements(
[...drPart, ...corrPart, ...rembPart, ...subPart, ...fraisPart]
[...drPart, ...corrPart, ...rembPart, ...subPart, ...fraisPart, ...pePart]
.filter(r => !mvFilter.projet || r._projet === mvFilter.projet)
).map(r => ({ ...r, _soldeIndicatif: mvSoldeIndicatifByRowId.get(r.id) }));
}, [mvRows, mvFilteredCorrections, mvFilteredRembMouvements, mvFilteredSouscriptionMouvements, mvFilteredFraisMouvements, mvFilter.type, mvFilter.projet, mvRembProjetById, mvSoldeIndicatifByRowId]);
}, [mvRows, mvFilteredCorrections, mvFilteredRembMouvements, mvFilteredSouscriptionMouvements, mvFilteredFraisMouvements, mvFilteredPeMouvements, mvFilter.type, mvFilter.projet, mvFilter.investType, mvRembProjetById, mvSoldeIndicatifByRowId]);
const {
pagedItems: mvPagedTableRows, page: mvPage, setPage: setMvPage,
@@ -2759,6 +2805,14 @@ export default function Plateformes() {
<option value="prelevement_local">Prélèvements étranger</option>
</select>
</div>
<div style={{ flex: 1, minWidth: 130 }}>
<label>Type d'investissement</label>
<select value={mvFilter.investType} onChange={e => setMvFilter(f => ({ ...f, investType: e.target.value }))}>
<option value="">Tous</option>
<option value="crowdlending">Crowdlending</option>
<option value="pe">Private Equity</option>
</select>
</div>
<div style={{ flex: 2, minWidth: 160 }}>
<label>Plateforme</label>
<div style={{
@@ -2866,7 +2920,7 @@ export default function Plateformes() {
color: (isBonus || isCashback) ? 'var(--warning)' : isInteretsPlateforme ? 'var(--primary)' : 'var(--success)',
}}>{isBonus ? 'bonus' : isCashback ? 'cashback' : isInteretsPlateforme ? 'intérêts plateforme' : 'remboursement'}</span>
</td>
<td className="text-muted">{r._projet || '—'}</td>
<td className="text-muted">{r._investType === 'pe' && <span style={{ display: 'inline-block', fontSize: 10, fontWeight: 700, letterSpacing: '0.03em', padding: '1px 5px', borderRadius: 4, marginRight: 6, background: 'color-mix(in srgb, var(--primary) 15%, transparent)', color: 'var(--primary)' }} title="Mouvement Private Equity">PE</span>}{r._projet || '—'}</td>
<td className="num success">
+ {fmtEUR(r.montant)}
</td>
@@ -2896,7 +2950,7 @@ export default function Plateformes() {
color,
}}>{label}</span>
</td>
<td className="text-muted">{r._projet || '—'}</td>
<td className="text-muted">{r._investType === 'pe' && <span style={{ display: 'inline-block', fontSize: 10, fontWeight: 700, letterSpacing: '0.03em', padding: '1px 5px', borderRadius: 4, marginRight: 6, background: 'color-mix(in srgb, var(--primary) 15%, transparent)', color: 'var(--primary)' }} title="Mouvement Private Equity">PE</span>}{r._projet || '—'}</td>
<td className="num danger">
− {fmtEUR(r.montant)}
</td>
@@ -2933,7 +2987,7 @@ export default function Plateformes() {
}}>RÉINVEST.</span>
)}
</td>
<td className="text-muted">{r._projet || '—'}</td>
<td className="text-muted">{r._investType === 'pe' && <span style={{ display: 'inline-block', fontSize: 10, fontWeight: 700, letterSpacing: '0.03em', padding: '1px 5px', borderRadius: 4, marginRight: 6, background: 'color-mix(in srgb, var(--primary) 15%, transparent)', color: 'var(--primary)' }} title="Mouvement Private Equity">PE</span>}{r._projet || '—'}</td>
<td className="num danger">
− {fmtEUR(r.montant)}
</td>
@@ -2945,11 +2999,17 @@ export default function Plateformes() {
if (r.type === 'frais') {
const horsPM = r.mode_reglement === 'compte_courant';
const rembLinked = r.mode_reglement === 'remboursement';
// Frais PE "à la source" (19/09/26) : déjà inclus dans le montant de la
// souscription (-800 € par ex.) — pas un débit supplémentaire du
// porte-monnaie. Même traitement visuel que HORS PM/DÉDUIT REMB. (grisé,
// badge, 0 d'effet sur le solde indicatif via signedMontant).
const dejaInclusSouscr = r._investType === 'pe' && r.mode_reglement === 'source';
const zeroImpact = horsPM || dejaInclusSouscr;
return (
<tr key={r.id}
className={`dr-row${mvSelectedRow?.id === r.id ? ' dr-row-selected' : ''}`}
onClick={() => setMvSelectedRow(mvSelectedRow?.id === r.id ? null : r)}
style={{ cursor: 'pointer', opacity: horsPM ? 0.75 : 1 }}>
style={{ cursor: 'pointer', opacity: zeroImpact ? 0.75 : 1 }}>
<td>{fmtDate(r.date_operation)}</td>
<td>{r.plateforme_nom}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>}
@@ -2981,9 +3041,19 @@ export default function Plateformes() {
verticalAlign: 'middle',
}} title="Déjà déduit du montant net du remboursement — aucun effet supplémentaire sur le solde du porte-monnaie">DÉDUIT REMB.</span>
)}
{dejaInclusSouscr && (
<span style={{
marginLeft: 6,
fontSize: 10, fontWeight: 600, letterSpacing: '0.03em',
padding: '1px 5px', borderRadius: 3,
background: 'var(--surface-2)',
color: 'var(--text-muted)',
verticalAlign: 'middle',
}} title="Déjà inclus dans le montant de la souscription — aucun effet supplémentaire sur le solde du porte-monnaie">DÉDUIT SOUSCR.</span>
)}
</td>
<td className="text-muted">{r._projet || '—'}</td>
<td className={horsPM ? 'num text-muted' : 'num danger'}>
<td className="text-muted">{r._investType === 'pe' && <span style={{ display: 'inline-block', fontSize: 10, fontWeight: 700, letterSpacing: '0.03em', padding: '1px 5px', borderRadius: 4, marginRight: 6, background: 'color-mix(in srgb, var(--primary) 15%, transparent)', color: 'var(--primary)' }} title="Mouvement Private Equity">PE</span>}{r._projet || '—'}</td>
<td className={zeroImpact ? 'num text-muted' : 'num danger'}>
− {fmtEUR(r.montant)}
</td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
@@ -2992,6 +3062,12 @@ export default function Plateformes() {
);
}
const isAuto = r.source === 'auto_remboursement';
// Dépôt/retrait PE (19/09/26) : même rendu que les dépôts crowdlending,
// mais SANS le menu d'édition/suppression — cette ligne vit dans la table
// depots_retraits sous le workspace PE, et l'éditer/la supprimer depuis ce
// menu (pensé pour le workspace crowdlending) réattribuerait sa gestion au
// mauvais endroit. Modifiable uniquement depuis le workspace PE d'origine.
const isForeign = r._investType === 'pe';
return (
<tr key={r.id}
className={`dr-row${mvSelectedRow?.id === r.id ? ' dr-row-selected' : ''}`}
@@ -3013,14 +3089,14 @@ export default function Plateformes() {
}}>AUTO</span>
)}
</td>
<td className="text-muted">{r._projet || '—'}</td>
<td className="text-muted">{isForeign && <span style={{ display: 'inline-block', fontSize: 10, fontWeight: 700, letterSpacing: '0.03em', padding: '1px 5px', borderRadius: 4, marginRight: 6, background: 'color-mix(in srgb, var(--primary) 15%, transparent)', color: 'var(--primary)' }} title="Mouvement Private Equity">PE</span>}{r._projet || '—'}</td>
<td className={`num ${r.type === 'depot' ? 'success' : 'danger'}`}
style={ isAuto ? { fontStyle: 'italic' } : undefined }>
{r.type === 'depot' ? '+' : '−'} {fmtEUR(r.montant)}
</td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} onClick={e => e.stopPropagation()}>
{!isAuto && (
{!isAuto && !isForeign && (
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 6px', borderRadius: 4, color: 'var(--text-muted)', lineHeight: 1, fontSize: 16 }}
onClick={e => mvOpenRowMenu(e, r)}>⋮</button>