This commit is contained in:
ocroguennec committed 2026-09-18 08:45:15 +02:00
1 parent 337d0c1a2c
commit d5792fb3e2
3 files changed
+163 -11

No files matched your search

+14
View File
@@ -3346,6 +3346,20 @@ db.exec(`
`); `);
db.exec('CREATE INDEX IF NOT EXISTS idx_invpeval_inv ON investissements_pe_valorisations(investissement_pe_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_invpeval_inv ON investissements_pe_valorisations(investissement_pe_id)');
// Migration : décomposition optionnelle de la valorisation en capital déployé / plus-value
// latente (18/09/26, demande Olivier — construire une carte de répartition façon Fundora sur la
// fiche détail PE). Capital non déployé n'est JAMAIS stocké : il se déduit à l'affichage/au
// calcul (montant_investi du deal − capital_deploye de cette valorisation). Colonnes nullables :
// une valorisation peut rester en saisie simple (un seul montant net), sans détail — c'est
// d'ailleurs le cas de toutes les valorisations existantes avant cette migration.
const invPeValCols = db.prepare('PRAGMA table_info(investissements_pe_valorisations)').all().map(c => c.name);
if (!invPeValCols.includes('capital_deploye')) {
db.exec('ALTER TABLE investissements_pe_valorisations ADD COLUMN capital_deploye REAL');
}
if (!invPeValCols.includes('plus_value_latente')) {
db.exec('ALTER TABLE investissements_pe_valorisations ADD COLUMN plus_value_latente REAL');
}
// Lien optionnel remboursement → deal PE (17/09/26) : une distribution (retour // Lien optionnel remboursement → deal PE (17/09/26) : une distribution (retour
// de capital, plus-value) perçue sur un deal PE est saisie comme un // de capital, plus-value) perçue sur un deal PE est saisie comme un
// remboursement bonus_plateforme/interets_plateforme, comme demandé par // remboursement bonus_plateforme/interets_plateforme, comme demandé par
+36 -5
View File
@@ -38,6 +38,12 @@ const Schema = z.object({
const ValorisationSchema = z.object({ const ValorisationSchema = z.object({
date_valorisation: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), date_valorisation: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
valorisation_nette: z.number().nonnegative(), valorisation_nette: z.number().nonnegative(),
// Répartition optionnelle (18/09/26, demande Olivier) : quand capital_deploye est renseigné,
// la valorisation_nette envoyée par le client est recalculée côté serveur (cf. POST
// /:id/valorisations) plutôt que reprise telle quelle — seule source de vérité sur ce calcul.
// Capital non déployé n'existe pas ici : toujours déduit (montant_investi du deal − capital_deploye).
capital_deploye: z.number().nonnegative().optional(),
plus_value_latente: z.number().optional(),
notes: z.string().optional(), notes: z.string().optional(),
}); });
@@ -264,13 +270,38 @@ router.get('/:id/valorisations', requireInvestisseur, (req, res, next) => {
router.post('/:id/valorisations', requireInvestisseur, (req, res, next) => { router.post('/:id/valorisations', requireInvestisseur, (req, res, next) => {
try { try {
const id = Number(req.params.id); const id = Number(req.params.id);
ownedDeal(req, id); const deal = ownedDeal(req, id);
const body = ValorisationSchema.parse(req.body); const body = ValorisationSchema.parse(req.body);
// Quand la répartition est renseignée (capital_deploye présent), la valorisation nette est
// recalculée ici plutôt que reprise du body : capital_deploye + plus_value_latente +
// capital_non_deploye (déduit = montant_investi du deal − capital_deploye, jamais négatif).
// Sans répartition (mode simple, historique), comportement inchangé : le montant saisi est
// utilisé tel quel et les deux colonnes restent NULL.
let valorisationNette = body.valorisation_nette;
let capitalDeploye = null;
let plusValueLatente = null;
if (body.capital_deploye != null) {
capitalDeploye = body.capital_deploye;
plusValueLatente = body.plus_value_latente ?? 0;
const capitalNonDeploye = Math.max(0, deal.montant_investi - capitalDeploye);
valorisationNette = Math.round((capitalDeploye + plusValueLatente + capitalNonDeploye) * 100) / 100;
}
const r = db.prepare(` const r = db.prepare(`
INSERT INTO investissements_pe_valorisations (investissement_pe_id, date_valorisation, valorisation_nette, notes) INSERT INTO investissements_pe_valorisations
VALUES (?,?,?,?) (investissement_pe_id, date_valorisation, valorisation_nette, capital_deploye, plus_value_latente, notes)
`).run(id, body.date_valorisation, body.valorisation_nette, body.notes || null); VALUES (?,?,?,?,?,?)
res.status(201).json({ id: r.lastInsertRowid, investissement_pe_id: id, ...body }); `).run(id, body.date_valorisation, valorisationNette, capitalDeploye, plusValueLatente, body.notes || null);
res.status(201).json({
id: r.lastInsertRowid,
investissement_pe_id: id,
date_valorisation: body.date_valorisation,
valorisation_nette: valorisationNette,
capital_deploye: capitalDeploye,
plus_value_latente: plusValueLatente,
notes: body.notes,
});
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
+113 -6
View File
@@ -52,7 +52,7 @@ const FRAIS_MODE_LABELS = {
remboursement: 'Retenu sur remboursement', remboursement: 'Retenu sur remboursement',
}; };
const emptyValForm = { date_valorisation: today(), valorisation_nette: '', 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: '' };
export default function InvestissementPeDetail() { export default function InvestissementPeDetail() {
@@ -243,9 +243,21 @@ export default function InvestissementPeDetail() {
const submitVal = async (e) => { const submitVal = async (e) => {
e?.preventDefault?.(); setValErr(null); e?.preventDefault?.(); setValErr(null);
try { try {
// Répartition optionnelle (18/09/26, demande Olivier) : si Capital déployé est renseigné,
// la Valorisation nette est recalculée ici à partir des 3 composantes plutôt que reprise
// du champ (qui est alors désactivé côté UI) — recalculée aussi côté serveur, qui reste la
// source de vérité finale (cf. POST /:id/valorisations).
const hasBreakdown = valForm.capital_deploye !== '';
const capitalDeploye = hasBreakdown ? (Number(valForm.capital_deploye) || 0) : undefined;
const plusValueLatente = hasBreakdown ? (Number(valForm.plus_value_latente) || 0) : undefined;
const valorisationNette = hasBreakdown
? Math.round((capitalDeploye + plusValueLatente + Math.max(0, Number(deal.montant_investi) - capitalDeploye)) * 100) / 100
: Number(valForm.valorisation_nette);
await api.post(`/investissements-pe/${id}/valorisations`, { await api.post(`/investissements-pe/${id}/valorisations`, {
date_valorisation: valForm.date_valorisation, date_valorisation: valForm.date_valorisation,
valorisation_nette: Number(valForm.valorisation_nette), valorisation_nette: valorisationNette,
capital_deploye: capitalDeploye,
plus_value_latente: plusValueLatente,
notes: valForm.notes || undefined, notes: valForm.notes || undefined,
}); });
setValForm(emptyValForm); setValForm(emptyValForm);
@@ -381,6 +393,36 @@ export default function InvestissementPeDetail() {
const totalFrais = fraisOps.reduce((s, f) => s + (f.montant || 0), 0); const totalFrais = fraisOps.reduce((s, f) => s + (f.montant || 0), 0);
// ── Valorisation : détail de répartition (18/09/26, demande Olivier) ──
// Formulaire "Ajouter une valorisation" : aperçu live de Capital non déployé / Valorisation
// nette pendant la saisie, dès que Capital déployé est renseigné (mode "détaillé" — sinon
// saisie simple d'un seul montant, comme avant). Capital non déployé n'est jamais saisi :
// toujours déduit du montant investi du deal moins le capital déployé.
const valFormHasBreakdown = valForm.capital_deploye !== '';
const valFormCapitalDeploye = valFormHasBreakdown ? (Number(valForm.capital_deploye) || 0) : 0;
const valFormPlusValue = valFormHasBreakdown ? (Number(valForm.plus_value_latente) || 0) : 0;
const valFormCapitalNonDeploye = valFormHasBreakdown
? Math.max(0, Number(deal.montant_investi) - valFormCapitalDeploye) : 0;
const valFormComputedNette = valFormHasBreakdown
? Math.round((valFormCapitalDeploye + valFormPlusValue + valFormCapitalNonDeploye) * 100) / 100
: null;
// Carte "Valorisation nette" (remplace l'ancienne tuile "Valorisation actuelle" du bloc
// Résumé) : reprend la dernière valorisation connue (valorisations[0], déjà triée DESC par
// l'API) et n'affiche la barre de répartition que si CETTE valorisation a le détail — sinon
// retombe sur un simple montant, comme avant (cas des valorisations saisies en mode simple, ou
// de celles antérieures à cette fonctionnalité).
const latestVal = valorisations[0];
const latestValHasBreakdown = latestVal && latestVal.capital_deploye != null;
const latestValTotal = latestVal ? latestVal.valorisation_nette : deal.montant_investi;
const latestValCapitalDeploye = latestValHasBreakdown ? latestVal.capital_deploye : 0;
const latestValPlusValue = latestValHasBreakdown ? (latestVal.plus_value_latente || 0) : 0;
const latestValCapitalNonDeploye = latestValHasBreakdown
? Math.max(0, latestValTotal - latestValCapitalDeploye - latestValPlusValue) : 0;
const valBarTotal = latestValCapitalDeploye + Math.max(0, latestValPlusValue) + latestValCapitalNonDeploye;
const pctDeploye = valBarTotal > 0 ? (latestValCapitalDeploye / valBarTotal) * 100 : 0;
const pctPlusValue = valBarTotal > 0 ? (Math.max(0, latestValPlusValue) / valBarTotal) * 100 : 0;
return ( return (
<> <>
<div className="topbar"> <div className="topbar">
@@ -400,8 +442,6 @@ export default function InvestissementPeDetail() {
<div className="dr-kpi-row" style={{ marginBottom: 0 }}> <div className="dr-kpi-row" style={{ marginBottom: 0 }}>
<div className="kpi"><div className="label">Montant investi</div> <div className="kpi"><div className="label">Montant investi</div>
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(deal.montant_investi)}</div></div> <div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(deal.montant_investi)}</div></div>
<div className="kpi"><div className="label">Valorisation actuelle</div>
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(deal.derniere_valorisation ?? deal.montant_investi)}</div></div>
<div className="kpi"><div className="label">TVPI</div> <div className="kpi"><div className="label">TVPI</div>
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtMultiple(deal.tvpi)}</div></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 className="kpi"><div className="label">Distributions cumulées</div>
@@ -409,6 +449,46 @@ export default function InvestissementPeDetail() {
<div className="kpi"><div className="label">Statut</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 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,
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 }}>
{fmtEUR(deal.derniere_valorisation ?? deal.montant_investi)}
</div>
{latestValHasBreakdown ? (
<>
<div style={{ display: 'flex', height: 8, borderRadius: 4, overflow: 'hidden', background: 'var(--surface-2)' }}>
<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>
</>
) : (
<div className="text-muted" style={{ fontSize: 13 }}>
{valorisations.length === 0
? 'Aucune valorisation enregistrée.'
: 'Pas de détail de répartition pour la dernière valorisation (saisie simple).'}
</div>
)}
</div>
<div className="text-muted" style={{ marginTop: 14, fontSize: 13 }}> <div className="text-muted" style={{ marginTop: 14, fontSize: 13 }}>
{deal.plateforme_nom} — {deal.investisseur_nom} · Souscrit le {fmtDate(deal.date_souscription)} {deal.plateforme_nom} — {deal.investisseur_nom} · Souscrit le {fmtDate(deal.date_souscription)}
{deal.strategie ? ` · ${deal.strategie}` : ''} {deal.strategie ? ` · ${deal.strategie}` : ''}
@@ -429,8 +509,28 @@ export default function InvestissementPeDetail() {
</div> </div>
<div> <div>
<label>Valorisation nette (€) *</label> <label>Valorisation nette (€) *</label>
<input type="number" step="0.01" min="0" required value={valForm.valorisation_nette} onChange={e => setValForm({ ...valForm, valorisation_nette: e.target.value })} /> <input type="number" step="0.01" min="0" required disabled={valFormHasBreakdown}
value={valFormHasBreakdown ? valFormComputedNette : valForm.valorisation_nette}
onChange={e => setValForm({ ...valForm, valorisation_nette: e.target.value })} />
</div> </div>
<div>
<label>Capital déployé (€)</label>
<input type="number" step="0.01" min="0" placeholder="optionnel — pour le détail de répartition"
value={valForm.capital_deploye}
onChange={e => setValForm({ ...valForm, capital_deploye: e.target.value })} />
</div>
<div>
<label>Capital non déployé (€)</label>
<input type="text" disabled value={valFormHasBreakdown ? fmtEUR(valFormCapitalNonDeploye) : '—'} />
</div>
{valFormHasBreakdown && (
<div style={{ gridColumn: '1 / -1' }}>
<label>Plus-value latente (€)</label>
<input type="number" step="0.01" placeholder="négatif si moins-value"
value={valForm.plus_value_latente}
onChange={e => setValForm({ ...valForm, plus_value_latente: e.target.value })} />
</div>
)}
<div style={{ gridColumn: '1 / -1' }}> <div style={{ gridColumn: '1 / -1' }}>
<label>Notes</label> <label>Notes</label>
<input value={valForm.notes} onChange={e => setValForm({ ...valForm, notes: e.target.value })} /> <input value={valForm.notes} onChange={e => setValForm({ ...valForm, notes: e.target.value })} />
@@ -450,7 +550,14 @@ export default function InvestissementPeDetail() {
{valorisations.map(v => ( {valorisations.map(v => (
<tr key={v.id}> <tr key={v.id}>
<td>{fmtDate(v.date_valorisation)}</td> <td>{fmtDate(v.date_valorisation)}</td>
<td className="num">{fmtEUR(v.valorisation_nette)}</td> <td className="num">
{fmtEUR(v.valorisation_nette)}
{v.capital_deploye != null && (
<div className="text-muted" style={{ fontSize: 11, fontWeight: 400 }}>
{fmtEUR(v.capital_deploye)} déployé · {(v.plus_value_latente || 0) >= 0 ? '+' : ''}{fmtEUR(v.plus_value_latente || 0)}
</div>
)}
</td>
<td className="text-muted">{v.notes || '—'}</td> <td className="text-muted">{v.notes || '—'}</td>
<td><button className="icon-btn" title="Supprimer" onClick={() => setValDeleteTarget(v)}>✕</button></td> <td><button className="icon-btn" title="Supprimer" onClick={() => setValDeleteTarget(v)}>✕</button></td>
</tr> </tr>