MAJ
This commit is contained in:
1 parent
4cd4cbce71
commit
27c4c57a58
6 files changed
+243
-49
No files matched your search
@@ -3409,4 +3409,21 @@ if (!invPeCols.includes('capital_investissable')) {
|
||||
}
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_remb_invpe ON remboursements(investissement_pe_id)');
|
||||
|
||||
// Migration : nature d'une distribution PE (18/09/26, demande Olivier — distinguer, au sein des
|
||||
// distributions reçues sur un deal PE, ce qui restitue le capital déployé ('capital') de ce qui
|
||||
// est un revenu d'intérêt versé sur le capital NON déployé, placé par le fonds sur un support
|
||||
// obligataire en attendant d'être appelé ('interet_non_deploye') — reprend la ligne "Obligation"
|
||||
// des captures Fundora, jusque-là non reconstituable faute de cette distinction. Nullable et
|
||||
// sans CHECK (validé côté Zod, cf. remboursements.js) : une distribution non classée n'est ni
|
||||
// l'un ni l'autre, elle reste comptée dans "Distributions" par défaut (cf.
|
||||
// investissementsPe.js::GET / et GET /:id). Sans effet sur les remboursements crowdlending
|
||||
// (type 'normal') ni sur les bonus (parrainage/plateforme), seul le type 'interets_plateforme'
|
||||
// (rebaptisé "Distribution" côté PE) l'utilise.
|
||||
{
|
||||
const rembCols3 = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name);
|
||||
if (!rembCols3.includes('distribution_nature')) {
|
||||
db.exec('ALTER TABLE remboursements ADD COLUMN distribution_nature TEXT');
|
||||
}
|
||||
}
|
||||
|
||||
export default db;
|
||||
@@ -110,7 +110,14 @@ router.get('/', (req, res) => {
|
||||
WHERE v.investissement_pe_id = ipe.id
|
||||
ORDER BY v.date_valorisation DESC, v.id DESC LIMIT 1) AS derniere_valorisation_date,
|
||||
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
|
||||
WHERE r.investissement_pe_id = ipe.id), 0) AS distributions_cumulees
|
||||
WHERE r.investissement_pe_id = ipe.id), 0) AS distributions_cumulees,
|
||||
-- Sous-ensemble des distributions ci-dessus classées "intérêt sur capital non déployé"
|
||||
-- (18/09/26, demande Olivier — reconstitue la ligne "Obligation" des captures Fundora,
|
||||
-- distincte de la restitution de capital déployé). Inclus dans distributions_cumulees ET
|
||||
-- dans le TVPI (valeur réellement perçue), juste isolé ici pour l'affichage détaillé.
|
||||
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
|
||||
WHERE r.investissement_pe_id = ipe.id AND r.distribution_nature = 'interet_non_deploye'), 0)
|
||||
AS obligation_cumulee
|
||||
FROM investissements_pe ipe
|
||||
JOIN plateformes p ON p.id = ipe.plateforme_id
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
||||
@@ -212,7 +219,14 @@ router.get('/:id', (req, res, next) => {
|
||||
WHERE v.investissement_pe_id = ipe.id
|
||||
ORDER BY v.date_valorisation DESC, v.id DESC LIMIT 1) AS derniere_valorisation_date,
|
||||
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
|
||||
WHERE r.investissement_pe_id = ipe.id), 0) AS distributions_cumulees
|
||||
WHERE r.investissement_pe_id = ipe.id), 0) AS distributions_cumulees,
|
||||
-- Sous-ensemble des distributions ci-dessus classées "intérêt sur capital non déployé"
|
||||
-- (18/09/26, demande Olivier — reconstitue la ligne "Obligation" des captures Fundora,
|
||||
-- distincte de la restitution de capital déployé). Inclus dans distributions_cumulees ET
|
||||
-- dans le TVPI (valeur réellement perçue), juste isolé ici pour l'affichage détaillé.
|
||||
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
|
||||
WHERE r.investissement_pe_id = ipe.id AND r.distribution_nature = 'interet_non_deploye'), 0)
|
||||
AS obligation_cumulee
|
||||
FROM investissements_pe ipe
|
||||
JOIN plateformes p ON p.id = ipe.plateforme_id
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
||||
|
||||
@@ -20,6 +20,12 @@ const Schema = z.object({
|
||||
// plateforme (ex. Fundora) — cf. project_workspaces_transformation.md.
|
||||
// Sans effet sur le type 'normal' (crowdlending).
|
||||
investissement_pe_id: z.number().int().positive().nullable().optional(),
|
||||
// Nature de la distribution pour un deal PE (18/09/26, demande Olivier) : 'capital' = restitution
|
||||
// du capital déployé, 'interet_non_deploye' = intérêt versé sur le capital NON déployé (support
|
||||
// obligataire du fonds) — cf. migration distribution_nature dans db/index.js. Optionnel : une
|
||||
// distribution non classée reste comptée dans "Distributions" (pas "Obligation") côté PE. Sans
|
||||
// effet hors type 'interets_plateforme'.
|
||||
distribution_nature: z.enum(['capital', 'interet_non_deploye']).nullable().optional(),
|
||||
date_remb: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
capital: z.number().nonnegative().default(0),
|
||||
cashback: z.number().nonnegative().default(0),
|
||||
@@ -337,14 +343,15 @@ router.post('/', (req, res, next) => {
|
||||
INSERT INTO remboursements
|
||||
(type, bonus_plateforme_id, bonus_investisseur_id, date_remb,
|
||||
capital, cashback, interets_bruts_avant_local, taxe_locale, interets_bruts, frais_ttc,
|
||||
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, source, notes, workspace_id, investissement_pe_id)
|
||||
VALUES ('interets_plateforme',?,?,?, 0,0,?,?,?,?,?,?, ?,?, ?, 'manuel', ?, ?, ?)
|
||||
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, source, notes, workspace_id,
|
||||
investissement_pe_id, distribution_nature)
|
||||
VALUES ('interets_plateforme',?,?,?, 0,0,?,?,?,?,?,?, ?,?, ?, 'manuel', ?, ?, ?, ?)
|
||||
`).run(
|
||||
body.bonus_plateforme_id, bonusInvestisseurId, body.date_remb,
|
||||
body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts, body.frais_ttc,
|
||||
body.prelev_sociaux, body.prelev_forfaitaire,
|
||||
interets_nets, net_recu, body.statut, body.notes || null,
|
||||
resolveActiveWorkspaceId(req), body.investissement_pe_id ?? null,
|
||||
resolveActiveWorkspaceId(req), body.investissement_pe_id ?? null, body.distribution_nature ?? null,
|
||||
);
|
||||
return res.status(201).json({ id: r.lastInsertRowid, ...body, interets_nets, net_recu });
|
||||
}
|
||||
@@ -513,12 +520,13 @@ router.put('/:id', (req, res, next) => {
|
||||
UPDATE remboursements
|
||||
SET date_remb=?, interets_bruts_avant_local=?, taxe_locale=?, interets_bruts=?, frais_ttc=?,
|
||||
prelev_sociaux=?, prelev_forfaitaire=?, interets_nets=?, net_recu=?, statut=?, notes=?,
|
||||
investissement_pe_id=?
|
||||
investissement_pe_id=?, distribution_nature=?
|
||||
WHERE id=?
|
||||
`).run(
|
||||
body.date_remb, body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts, body.frais_ttc,
|
||||
body.prelev_sociaux, body.prelev_forfaitaire,
|
||||
interets_nets, net_recu, body.statut, body.notes || null, body.investissement_pe_id ?? null, id,
|
||||
interets_nets, net_recu, body.statut, body.notes || null, body.investissement_pe_id ?? null,
|
||||
body.distribution_nature ?? null, id,
|
||||
);
|
||||
} else {
|
||||
const bonusInvestisseurId = body.bonus_investisseur_id ?? existing.bonus_investisseur_id;
|
||||
|
||||
@@ -207,6 +207,16 @@ export default function RembFormModal({
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{!locked && isInteretsPlateforme && dealOptions.length > 0 && (
|
||||
<div>
|
||||
<label>Nature de la distribution</label>
|
||||
<select value={form.distribution_nature || ''} onChange={e => onFieldChange('distribution_nature', e.target.value)}>
|
||||
<option value="">— Non précisé —</option>
|
||||
<option value="capital">Restitution de capital déployé</option>
|
||||
<option value="interet_non_deploye">Intérêt sur capital non déployé</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{!locked && isBonus && (
|
||||
<div>
|
||||
<label>Date *</label>
|
||||
|
||||
@@ -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 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>
|
||||
<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>
|
||||
|
||||
{/* 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 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>
|
||||
{deal.notes && <div className="text-muted" style={{ marginTop: 8, fontSize: 13 }}>{deal.notes}</div>}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* ── Valorisations ── */}
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
Reference in new issue
Block a user