This commit is contained in:
ocroguennec committed 2026-09-18 10:35:43 +02:00
1 parent 4cd4cbce71
commit 27c4c57a58
6 files changed
+243 -49

No files matched your search

+167 -41
View File
@@ -55,6 +55,34 @@ const FRAIS_MODE_LABELS = {
const emptyValForm = { date_valorisation: today(), valorisation_nette: '', capital_deploye: '', plus_value_latente: '', notes: '' };
const emptyFraisForm = { montant: '', mode_reglement: 'source', compte_id: '', date_operation: today(), categorie: 'gestion', notes: '' };
// ── Tableau de bord "Montant souscrit / Valorisation nette" (18/09/26, demande Olivier —
// reprise de la disposition des captures Fundora : 2 cartes avec barre de répartition + 3
// colonnes Déploiement/Performance/Frais). Petits composants de présentation partagés par les
// deux cartes et les 3 colonnes ci-dessous.
function LegendDot({ color, outline, label, value }) {
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span style={{
width: 9, height: 9, borderRadius: '50%', background: color,
border: outline ? '1px solid var(--border)' : 'none', display: 'inline-block',
}} />
{label} <b>{fmtEUR(value)}</b>
</span>
);
}
function StatRow({ label, value, caption, valueColor }) {
return (
<div style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>{label}</div>
<div style={{ fontWeight: 700, fontSize: 14, color: valueColor, whiteSpace: 'nowrap' }}>{value}</div>
</div>
{caption && <div className="text-muted" style={{ fontSize: 11, marginTop: 2 }}>{caption}</div>}
</div>
);
}
export default function InvestissementPeDetail() {
const { id } = useParams();
const navigate = useNavigate();
@@ -431,6 +459,50 @@ export default function InvestissementPeDetail() {
const pctDeploye = valBarTotal > 0 ? (latestValCapitalDeploye / valBarTotal) * 100 : 0;
const pctPlusValue = valBarTotal > 0 ? (Math.max(0, latestValPlusValue) / valBarTotal) * 100 : 0;
// Carte "Montant souscrit" + colonnes Déploiement/Performance/Frais (18/09/26, reprise du
// tableau de bord Fundora, demande Olivier — même disposition, même graphique, même tableau à
// 3 colonnes). Frais d'entrée/de gestion "à la source" : mêmes lignes frais_operations que
// celles pré-remplies dans "Modifier l'investissement" (openEditDeal ci-dessus).
const fraisEntreeRow = fraisOps.find(f => f.categorie === 'souscription');
const fraisGestionRow = fraisOps.find(f => f.categorie === 'gestion' && f.origine === 'souscription');
const fraisEntreeMontant = fraisEntreeRow?.montant || 0;
const fraisGestionMontant = fraisGestionRow?.montant || 0;
const capitalInvestissable = Number(deal.capital_investissable ?? deal.montant_investi);
const pctCapitalAuTravail = capitalInvestissable > 0 ? (latestValCapitalDeploye / capitalInvestissable) * 100 : 0;
// TVPI "du sous-jacent" = multiple sur le seul capital déployé (contrairement au TVPI global,
// qui rapporte à tout le montant souscrit) — dérivé de la plus-value latente saisie, pas un
// champ à part : (capital déployé + plus-value latente) / capital déployé.
const tvpiSousJacent = latestValHasBreakdown && latestValCapitalDeploye > 0
? (latestValCapitalDeploye + latestValPlusValue) / latestValCapitalDeploye
: null;
// "Valeur totale cible" = montant souscrit × objectif, uniquement quand l'objectif est un
// multiple simple ("x4,5") — objectif reste volontairement en texte libre côté deal (peut
// aussi être une fourchette de taux, ex. "9-12 %/an" : pas de calcul possible dans ce cas,
// affiche "—").
const objectifMultipleMatch = deal.objectif && deal.objectif.match(/^x\s*(\d+(?:[.,]\d+)?)/i);
const valeurTotaleCible = objectifMultipleMatch
? deal.montant_investi * parseFloat(objectifMultipleMatch[1].replace(',', '.'))
: null;
// Barre "Montant souscrit" : mêmes 4 composantes que le camembert Fundora, mais rapportées au
// montant souscrit BRUT (pas au capital investissable) puisque les frais en font partie.
const pctFraisEntreeSouscrit = deal.montant_investi > 0 ? (fraisEntreeMontant / deal.montant_investi) * 100 : 0;
const pctFraisGestionSouscrit = deal.montant_investi > 0 ? (fraisGestionMontant / deal.montant_investi) * 100 : 0;
const pctDeployeSouscrit = deal.montant_investi > 0 ? (latestValCapitalDeploye / deal.montant_investi) * 100 : 0;
// "Obligation" (18/09/26, demande Olivier) : sous-ensemble des distributions classées "intérêt
// sur capital non déployé" (cf. distribution_nature dans RembFormModal.jsx), reconstituant la
// ligne du même nom sur les captures Fundora — calculé par le backend (obligation_cumulee,
// somme des remboursements de ce deal tagués ainsi), jamais une estimation/un taux fabriqué
// localement. "Distributions" affiché ci-dessous devient donc le reste (capital + non classé),
// les deux sommant toujours à distributions_cumulees (qui reste la base du calcul du TVPI,
// inchangée — cf. GET /:id, investissementsPe.js).
const obligationCumulee = deal.obligation_cumulee || 0;
const distributionsCapital = (deal.distributions_cumulees || 0) - obligationCumulee;
return (
<>
<div className="topbar">
@@ -446,30 +518,46 @@ export default function InvestissementPeDetail() {
<div style={{ padding: '0 24px' }}>
{/* ── Résumé ── */}
<div className="card" style={{ marginBottom: 16 }}>
<div className="dr-kpi-row" style={{ marginBottom: 0 }}>
<div className="kpi"><div className="label">Montant investi</div>
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(deal.montant_investi)}</div>
{deal.capital_investissable != null && deal.capital_investissable !== deal.montant_investi && (
<div className="text-muted" style={{ fontSize: 11, marginTop: 2 }}>
dont {fmtEUR(deal.capital_investissable)} investissables (après frais à la source)
</div>
)}</div>
<div className="kpi"><div className="label">TVPI</div>
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtMultiple(deal.tvpi)}</div></div>
<div className="kpi"><div className="label">Distributions cumulées</div>
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(deal.distributions_cumulees)}</div></div>
<div className="kpi"><div className="label">Statut</div>
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{STATUT_LABELS[deal.statut] || deal.statut}</div></div>
<div className="text-muted" style={{ fontSize: 13, marginBottom: 12 }}>
{deal.plateforme_nom} — {deal.investisseur_nom} · Souscrit le {fmtDate(deal.date_souscription)}
{deal.strategie ? ` · ${deal.strategie}` : ''}
{deal.objectif ? ` · Objectif : ${deal.objectif}` : ''}
{deal.reference ? ` · Réf. ${deal.reference}` : ''}
{deal.statut !== 'valide' ? ` · ${STATUT_LABELS[deal.statut] || deal.statut}` : ''}
</div>
{deal.notes && <div className="text-muted" style={{ marginBottom: 12, fontSize: 13 }}>{deal.notes}</div>}
{/* Tableau de bord "Montant souscrit / Valorisation nette" (18/09/26, reprise de la
disposition des captures Fundora d'Olivier : 2 cartes avec barre de répartition, puis
3 colonnes Déploiement/Performance/Frais). N'affiche les graphiques que si la
dernière valorisation a le détail de répartition (saisie optionnelle, formulaire
"Valorisations" plus bas) — sinon retombe sur un résumé minimal. */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
<div className="card">
<h4 style={{ margin: '0 0 2px' }}>1. Montant souscrit</h4>
<div className="text-muted" style={{ fontSize: 12, marginBottom: 10 }}>
Engagement total, décaissé en totalité à la souscription
</div>
<div style={{ fontSize: '1.5rem', fontWeight: 700, marginBottom: 10 }}>{fmtEUR(deal.montant_investi)}</div>
<div style={{ display: 'flex', height: 8, borderRadius: 4, overflow: 'hidden', background: 'var(--surface-2)' }}>
<div style={{ width: `${pctFraisEntreeSouscrit}%`, background: 'var(--text)' }} />
<div style={{ width: `${pctFraisGestionSouscrit}%`, background: 'var(--text)' }} />
<div style={{ width: `${pctDeployeSouscrit}%`, background: 'var(--primary)' }} />
</div>
<div style={{ display: 'flex', gap: 16, marginTop: 10, fontSize: 12, flexWrap: 'wrap' }}>
<LegendDot color="var(--text)" label="Frais d'entrée" value={fraisEntreeMontant} />
<LegendDot color="var(--text)" label="Frais de gestion" value={fraisGestionMontant} />
<LegendDot color="var(--primary)" label="Capital déployé" value={latestValCapitalDeploye} />
<LegendDot color="var(--surface-2)" outline label="Capital non déployé" value={latestValCapitalNonDeploye} />
</div>
</div>
{/* Valorisation nette — remplace l'ancienne tuile "Valorisation actuelle" (18/09/26,
demande Olivier : reprendre l'esprit des captures Fundora — répartition capital
déployé / plus-value latente / capital non déployé). Barre affichée seulement quand
la dernière valorisation a ce détail (saisie optionnelle, formulaire ci-dessous). */}
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
<div className="label">Valorisation nette</div>
<div style={{ fontSize: '1.3rem', fontWeight: 700, marginTop: 4, marginBottom: latestValHasBreakdown ? 10 : 4 }}>
<div className="card">
<h4 style={{ margin: '0 0 2px' }}>2. Valorisation nette</h4>
<div className="text-muted" style={{ fontSize: 12, marginBottom: 10 }}>
Capital déployé + plus-value latente + capital non déployé
</div>
<div style={{ fontSize: '1.5rem', fontWeight: 700, marginBottom: 10 }}>
{fmtEUR(deal.derniere_valorisation ?? deal.montant_investi)}
</div>
{latestValHasBreakdown ? (
@@ -478,19 +566,14 @@ export default function InvestissementPeDetail() {
<div style={{ width: `${pctDeploye}%`, background: 'var(--primary)' }} />
<div style={{ width: `${pctPlusValue}%`, background: latestValPlusValue >= 0 ? 'var(--success)' : 'var(--danger)' }} />
</div>
<div style={{ display: 'flex', gap: 20, marginTop: 10, fontSize: 13, flexWrap: 'wrap' }}>
<span>
<span style={{ display: 'inline-block', width: 9, height: 9, borderRadius: '50%', background: 'var(--primary)', marginRight: 6 }} />
Capital déployé <b>{fmtEUR(latestValCapitalDeploye)}</b>
</span>
<span>
<span style={{ display: 'inline-block', width: 9, height: 9, borderRadius: '50%', background: latestValPlusValue >= 0 ? 'var(--success)' : 'var(--danger)', marginRight: 6 }} />
{latestValPlusValue >= 0 ? 'Plus-value latente' : 'Moins-value latente'} <b>{fmtEUR(latestValPlusValue)}</b>
</span>
<span>
<span style={{ display: 'inline-block', width: 9, height: 9, borderRadius: '50%', background: 'var(--surface-2)', border: '1px solid var(--border)', marginRight: 6 }} />
Capital non déployé <b>{fmtEUR(latestValCapitalNonDeploye)}</b>
</span>
<div style={{ display: 'flex', gap: 16, marginTop: 10, fontSize: 12, flexWrap: 'wrap' }}>
<LegendDot color="var(--primary)" label="Capital déployé" value={latestValCapitalDeploye} />
<LegendDot
color={latestValPlusValue >= 0 ? 'var(--success)' : 'var(--danger)'}
label={latestValPlusValue >= 0 ? 'Plus-value latente' : 'Moins-value latente'}
value={latestValPlusValue}
/>
<LegendDot color="var(--surface-2)" outline label="Capital non déployé" value={latestValCapitalNonDeploye} />
</div>
</>
) : (
@@ -501,14 +584,57 @@ export default function InvestissementPeDetail() {
</div>
)}
</div>
</div>
<div className="text-muted" style={{ marginTop: 14, fontSize: 13 }}>
{deal.plateforme_nom} — {deal.investisseur_nom} · Souscrit le {fmtDate(deal.date_souscription)}
{deal.strategie ? ` · ${deal.strategie}` : ''}
{deal.objectif ? ` · Objectif : ${deal.objectif}` : ''}
{deal.reference ? ` · Réf. ${deal.reference}` : ''}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16, marginBottom: 16 }}>
<div className="card">
<div style={{ fontSize: 'var(--fs-xs)', textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--text-muted)', marginBottom: 12 }}>Déploiement</div>
<StatRow
label="Pourcentage du capital au travail"
value={`${pctCapitalAuTravail.toFixed(1).replace('.', ',')} %`}
caption="Pourcentage appelé par le fonds"
/>
<StatRow
label="Capital déployé"
value={fmtEUR(latestValCapitalDeploye)}
caption="(Montant souscrit − frais) × % d'appels de fonds"
/>
<StatRow
label="Capital non déployé"
value={fmtEUR(latestValCapitalNonDeploye)}
caption="(Montant souscrit − frais) × (1 − % d'appels de fonds)"
/>
</div>
<div className="card">
<div style={{ fontSize: 'var(--fs-xs)', textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--text-muted)', marginBottom: 12 }}>Performance</div>
<StatRow
label="Valorisation nette"
value={fmtEUR(deal.derniere_valorisation ?? deal.montant_investi)}
valueColor="var(--success)"
caption={<>
Capital déployé + plus-value latente + capital non déployé
{tvpiSousJacent != null && <><br />TVPI du sous-jacent {fmtMultiple(tvpiSousJacent)}</>}
</>}
/>
<StatRow label="TVPI" value={fmtMultiple(deal.tvpi)} caption="Valorisation nette / montant souscrit" />
<StatRow label="Distributions" value={fmtEUR(distributionsCapital)} caption="Restitution de capital déployé, remboursements non classés inclus" />
<StatRow label="Obligation" value={fmtEUR(obligationCumulee)} caption="Intérêt versé sur le capital non déployé (remboursements classés ainsi)" />
<StatRow
label="Valeur totale cible"
value={valeurTotaleCible != null ? fmtEUR(valeurTotaleCible) : '—'}
caption="Montant souscrit × objectif de l'opportunité"
/>
</div>
<div className="card">
<div style={{ fontSize: 'var(--fs-xs)', textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--text-muted)', marginBottom: 12 }}>Frais</div>
<StatRow
label="Frais totaux"
value={fmtEUR(fraisEntreeMontant + fraisGestionMontant)}
caption={`Frais d'entrée ${fmtEUR(fraisEntreeMontant)} · Frais de gestion ${fmtEUR(fraisGestionMontant)}`}
/>
</div>
{deal.notes && <div className="text-muted" style={{ marginTop: 8, fontSize: 13 }}>{deal.notes}</div>}
</div>
{/* ── Valorisations ── */}
+20 -1
View File
@@ -21,6 +21,12 @@ const TYPE_LABELS = {
interets_plateforme: 'Distribution',
};
// Nature de la distribution (18/09/26) — affichée en sous-texte sous le type dans le tableau.
const DISTRIBUTION_NATURE_LABELS = {
capital: 'Restitution de capital',
interet_non_deploye: 'Intérêt sur capital non déployé',
};
const MOIS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
const MOIS_ABBR = ['Jan','Fév','Mar','Avr','Mai','Juin','Juil','Août','Sep','Oct','Nov','Déc'];
@@ -164,6 +170,10 @@ const emptyRembForm = {
// rattacher une distribution à un investissement précis pour calculer son
// TVPI, quand plusieurs deals partagent une même plateforme (ex. Fundora).
investissement_pe_id: '',
// Nature de la distribution (18/09/26, demande Olivier) : distingue restitution de capital
// déployé vs. intérêt sur capital non déployé — cf. RembFormModal.jsx. Optionnel, uniquement
// pertinent pour le type "Distribution" (interets_plateforme).
distribution_nature: '',
date_remb: today(),
statut: 'paye',
notes: '',
@@ -338,6 +348,7 @@ export default function RemboursementsSimple() {
bonus_plateforme_id: String(r.bonus_plateforme_id || ''),
bonus_investisseur_id: String(r.bonus_investisseur_id || ''),
investissement_pe_id: r.investissement_pe_id ? String(r.investissement_pe_id) : '',
distribution_nature: r.distribution_nature || '',
date_remb: r.date_remb,
statut: r.statut,
notes: r.notes || '',
@@ -419,6 +430,7 @@ export default function RemboursementsSimple() {
bonus_plateforme_id: Number(form.bonus_plateforme_id),
bonus_investisseur_id: Number(form.bonus_investisseur_id) || undefined,
investissement_pe_id: Number(form.investissement_pe_id) || undefined,
distribution_nature: (!isBonusForm && form.distribution_nature) ? form.distribution_nature : undefined,
date_remb: form.date_remb,
statut: form.statut,
notes: form.notes || undefined,
@@ -840,7 +852,14 @@ export default function RemboursementsSimple() {
<td>{fmtDate(r.date_remb)}</td>
<td>{r.plateforme_nom}</td>
{multiDetenteur && <td className="text-muted">{r.plateforme_detenteur_nom || '—'}</td>}
<td>{TYPE_LABELS[r.type] || r.type}</td>
<td>
{TYPE_LABELS[r.type] || r.type}
{r.distribution_nature && (
<div className="text-muted" style={{ fontSize: 11 }}>
{DISTRIBUTION_NATURE_LABELS[r.distribution_nature] || r.distribution_nature}
</div>
)}
</td>
<td className="num">{fmtEUR(r.net_recu)}</td>
<td className="text-muted">{statutLabel(r.statut)}</td>
<td onClick={e => e.stopPropagation()}>