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)');
|
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;
|
export default db;
|
||||||
@@ -110,7 +110,14 @@ router.get('/', (req, res) => {
|
|||||||
WHERE v.investissement_pe_id = ipe.id
|
WHERE v.investissement_pe_id = ipe.id
|
||||||
ORDER BY v.date_valorisation DESC, v.id DESC LIMIT 1) AS derniere_valorisation_date,
|
ORDER BY v.date_valorisation DESC, v.id DESC LIMIT 1) AS derniere_valorisation_date,
|
||||||
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
|
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
|
FROM investissements_pe ipe
|
||||||
JOIN plateformes p ON p.id = ipe.plateforme_id
|
JOIN plateformes p ON p.id = ipe.plateforme_id
|
||||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_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
|
WHERE v.investissement_pe_id = ipe.id
|
||||||
ORDER BY v.date_valorisation DESC, v.id DESC LIMIT 1) AS derniere_valorisation_date,
|
ORDER BY v.date_valorisation DESC, v.id DESC LIMIT 1) AS derniere_valorisation_date,
|
||||||
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
|
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
|
FROM investissements_pe ipe
|
||||||
JOIN plateformes p ON p.id = ipe.plateforme_id
|
JOIN plateformes p ON p.id = ipe.plateforme_id
|
||||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_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.
|
// plateforme (ex. Fundora) — cf. project_workspaces_transformation.md.
|
||||||
// Sans effet sur le type 'normal' (crowdlending).
|
// Sans effet sur le type 'normal' (crowdlending).
|
||||||
investissement_pe_id: z.number().int().positive().nullable().optional(),
|
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}$/),
|
date_remb: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
capital: z.number().nonnegative().default(0),
|
capital: z.number().nonnegative().default(0),
|
||||||
cashback: z.number().nonnegative().default(0),
|
cashback: z.number().nonnegative().default(0),
|
||||||
@@ -337,14 +343,15 @@ router.post('/', (req, res, next) => {
|
|||||||
INSERT INTO remboursements
|
INSERT INTO remboursements
|
||||||
(type, bonus_plateforme_id, bonus_investisseur_id, date_remb,
|
(type, bonus_plateforme_id, bonus_investisseur_id, date_remb,
|
||||||
capital, cashback, interets_bruts_avant_local, taxe_locale, interets_bruts, frais_ttc,
|
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)
|
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, source, notes, workspace_id,
|
||||||
VALUES ('interets_plateforme',?,?,?, 0,0,?,?,?,?,?,?, ?,?, ?, 'manuel', ?, ?, ?)
|
investissement_pe_id, distribution_nature)
|
||||||
|
VALUES ('interets_plateforme',?,?,?, 0,0,?,?,?,?,?,?, ?,?, ?, 'manuel', ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
body.bonus_plateforme_id, bonusInvestisseurId, body.date_remb,
|
body.bonus_plateforme_id, bonusInvestisseurId, body.date_remb,
|
||||||
body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts, body.frais_ttc,
|
body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts, body.frais_ttc,
|
||||||
body.prelev_sociaux, body.prelev_forfaitaire,
|
body.prelev_sociaux, body.prelev_forfaitaire,
|
||||||
interets_nets, net_recu, body.statut, body.notes || null,
|
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 });
|
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
|
UPDATE remboursements
|
||||||
SET date_remb=?, interets_bruts_avant_local=?, taxe_locale=?, interets_bruts=?, frais_ttc=?,
|
SET date_remb=?, interets_bruts_avant_local=?, taxe_locale=?, interets_bruts=?, frais_ttc=?,
|
||||||
prelev_sociaux=?, prelev_forfaitaire=?, interets_nets=?, net_recu=?, statut=?, notes=?,
|
prelev_sociaux=?, prelev_forfaitaire=?, interets_nets=?, net_recu=?, statut=?, notes=?,
|
||||||
investissement_pe_id=?
|
investissement_pe_id=?, distribution_nature=?
|
||||||
WHERE id=?
|
WHERE id=?
|
||||||
`).run(
|
`).run(
|
||||||
body.date_remb, body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts, body.frais_ttc,
|
body.date_remb, body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts, body.frais_ttc,
|
||||||
body.prelev_sociaux, body.prelev_forfaitaire,
|
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 {
|
} else {
|
||||||
const bonusInvestisseurId = body.bonus_investisseur_id ?? existing.bonus_investisseur_id;
|
const bonusInvestisseurId = body.bonus_investisseur_id ?? existing.bonus_investisseur_id;
|
||||||
|
|||||||
@@ -207,6 +207,16 @@ export default function RembFormModal({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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 && (
|
{!locked && isBonus && (
|
||||||
<div>
|
<div>
|
||||||
<label>Date *</label>
|
<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 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: '' };
|
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() {
|
export default function InvestissementPeDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -431,6 +459,50 @@ export default function InvestissementPeDetail() {
|
|||||||
const pctDeploye = valBarTotal > 0 ? (latestValCapitalDeploye / valBarTotal) * 100 : 0;
|
const pctDeploye = valBarTotal > 0 ? (latestValCapitalDeploye / valBarTotal) * 100 : 0;
|
||||||
const pctPlusValue = valBarTotal > 0 ? (Math.max(0, latestValPlusValue) / 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="topbar">
|
<div className="topbar">
|
||||||
@@ -446,30 +518,46 @@ export default function InvestissementPeDetail() {
|
|||||||
|
|
||||||
<div style={{ padding: '0 24px' }}>
|
<div style={{ padding: '0 24px' }}>
|
||||||
{/* ── Résumé ── */}
|
{/* ── Résumé ── */}
|
||||||
<div className="card" style={{ marginBottom: 16 }}>
|
<div className="text-muted" style={{ fontSize: 13, marginBottom: 12 }}>
|
||||||
<div className="dr-kpi-row" style={{ marginBottom: 0 }}>
|
{deal.plateforme_nom} — {deal.investisseur_nom} · Souscrit le {fmtDate(deal.date_souscription)}
|
||||||
<div className="kpi"><div className="label">Montant investi</div>
|
{deal.strategie ? ` · ${deal.strategie}` : ''}
|
||||||
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(deal.montant_investi)}</div>
|
{deal.objectif ? ` · Objectif : ${deal.objectif}` : ''}
|
||||||
{deal.capital_investissable != null && deal.capital_investissable !== deal.montant_investi && (
|
{deal.reference ? ` · Réf. ${deal.reference}` : ''}
|
||||||
<div className="text-muted" style={{ fontSize: 11, marginTop: 2 }}>
|
{deal.statut !== 'valide' ? ` · ${STATUT_LABELS[deal.statut] || deal.statut}` : ''}
|
||||||
dont {fmtEUR(deal.capital_investissable)} investissables (après frais à la source)
|
</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>
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Valorisation nette — remplace l'ancienne tuile "Valorisation actuelle" (18/09/26,
|
<div className="card">
|
||||||
demande Olivier : reprendre l'esprit des captures Fundora — répartition capital
|
<h4 style={{ margin: '0 0 2px' }}>2. Valorisation nette</h4>
|
||||||
déployé / plus-value latente / capital non déployé). Barre affichée seulement quand
|
<div className="text-muted" style={{ fontSize: 12, marginBottom: 10 }}>
|
||||||
la dernière valorisation a ce détail (saisie optionnelle, formulaire ci-dessous). */}
|
Capital déployé + plus-value latente + capital non déployé
|
||||||
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
|
</div>
|
||||||
<div className="label">Valorisation nette</div>
|
<div style={{ fontSize: '1.5rem', fontWeight: 700, marginBottom: 10 }}>
|
||||||
<div style={{ fontSize: '1.3rem', fontWeight: 700, marginTop: 4, marginBottom: latestValHasBreakdown ? 10 : 4 }}>
|
|
||||||
{fmtEUR(deal.derniere_valorisation ?? deal.montant_investi)}
|
{fmtEUR(deal.derniere_valorisation ?? deal.montant_investi)}
|
||||||
</div>
|
</div>
|
||||||
{latestValHasBreakdown ? (
|
{latestValHasBreakdown ? (
|
||||||
@@ -478,19 +566,14 @@ export default function InvestissementPeDetail() {
|
|||||||
<div style={{ width: `${pctDeploye}%`, background: 'var(--primary)' }} />
|
<div style={{ width: `${pctDeploye}%`, background: 'var(--primary)' }} />
|
||||||
<div style={{ width: `${pctPlusValue}%`, background: latestValPlusValue >= 0 ? 'var(--success)' : 'var(--danger)' }} />
|
<div style={{ width: `${pctPlusValue}%`, background: latestValPlusValue >= 0 ? 'var(--success)' : 'var(--danger)' }} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 20, marginTop: 10, fontSize: 13, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 16, marginTop: 10, fontSize: 12, flexWrap: 'wrap' }}>
|
||||||
<span>
|
<LegendDot color="var(--primary)" label="Capital déployé" value={latestValCapitalDeploye} />
|
||||||
<span style={{ display: 'inline-block', width: 9, height: 9, borderRadius: '50%', background: 'var(--primary)', marginRight: 6 }} />
|
<LegendDot
|
||||||
Capital déployé <b>{fmtEUR(latestValCapitalDeploye)}</b>
|
color={latestValPlusValue >= 0 ? 'var(--success)' : 'var(--danger)'}
|
||||||
</span>
|
label={latestValPlusValue >= 0 ? 'Plus-value latente' : 'Moins-value latente'}
|
||||||
<span>
|
value={latestValPlusValue}
|
||||||
<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>
|
<LegendDot color="var(--surface-2)" outline label="Capital non déployé" value={latestValCapitalNonDeploye} />
|
||||||
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -501,14 +584,57 @@ export default function InvestissementPeDetail() {
|
|||||||
</div>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* ── Valorisations ── */}
|
{/* ── Valorisations ── */}
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ const TYPE_LABELS = {
|
|||||||
interets_plateforme: 'Distribution',
|
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_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'];
|
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
|
// rattacher une distribution à un investissement précis pour calculer son
|
||||||
// TVPI, quand plusieurs deals partagent une même plateforme (ex. Fundora).
|
// TVPI, quand plusieurs deals partagent une même plateforme (ex. Fundora).
|
||||||
investissement_pe_id: '',
|
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(),
|
date_remb: today(),
|
||||||
statut: 'paye',
|
statut: 'paye',
|
||||||
notes: '',
|
notes: '',
|
||||||
@@ -338,6 +348,7 @@ export default function RemboursementsSimple() {
|
|||||||
bonus_plateforme_id: String(r.bonus_plateforme_id || ''),
|
bonus_plateforme_id: String(r.bonus_plateforme_id || ''),
|
||||||
bonus_investisseur_id: String(r.bonus_investisseur_id || ''),
|
bonus_investisseur_id: String(r.bonus_investisseur_id || ''),
|
||||||
investissement_pe_id: r.investissement_pe_id ? String(r.investissement_pe_id) : '',
|
investissement_pe_id: r.investissement_pe_id ? String(r.investissement_pe_id) : '',
|
||||||
|
distribution_nature: r.distribution_nature || '',
|
||||||
date_remb: r.date_remb,
|
date_remb: r.date_remb,
|
||||||
statut: r.statut,
|
statut: r.statut,
|
||||||
notes: r.notes || '',
|
notes: r.notes || '',
|
||||||
@@ -419,6 +430,7 @@ export default function RemboursementsSimple() {
|
|||||||
bonus_plateforme_id: Number(form.bonus_plateforme_id),
|
bonus_plateforme_id: Number(form.bonus_plateforme_id),
|
||||||
bonus_investisseur_id: Number(form.bonus_investisseur_id) || undefined,
|
bonus_investisseur_id: Number(form.bonus_investisseur_id) || undefined,
|
||||||
investissement_pe_id: Number(form.investissement_pe_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,
|
date_remb: form.date_remb,
|
||||||
statut: form.statut,
|
statut: form.statut,
|
||||||
notes: form.notes || undefined,
|
notes: form.notes || undefined,
|
||||||
@@ -840,7 +852,14 @@ export default function RemboursementsSimple() {
|
|||||||
<td>{fmtDate(r.date_remb)}</td>
|
<td>{fmtDate(r.date_remb)}</td>
|
||||||
<td>{r.plateforme_nom}</td>
|
<td>{r.plateforme_nom}</td>
|
||||||
{multiDetenteur && <td className="text-muted">{r.plateforme_detenteur_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="num">{fmtEUR(r.net_recu)}</td>
|
||||||
<td className="text-muted">{statutLabel(r.statut)}</td>
|
<td className="text-muted">{statutLabel(r.statut)}</td>
|
||||||
<td onClick={e => e.stopPropagation()}>
|
<td onClick={e => e.stopPropagation()}>
|
||||||
|
|||||||
Reference in new issue
Block a user