Maj
This commit is contained in:
1 parent
337d0c1a2c
commit
d5792fb3e2
3 files changed
+163
-11
No files matched your search
@@ -3346,6 +3346,20 @@ db.exec(`
|
||||
`);
|
||||
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
|
||||
// de capital, plus-value) perçue sur un deal PE est saisie comme un
|
||||
// remboursement bonus_plateforme/interets_plateforme, comme demandé par
|
||||
|
||||
@@ -38,6 +38,12 @@ const Schema = z.object({
|
||||
const ValorisationSchema = z.object({
|
||||
date_valorisation: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
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(),
|
||||
});
|
||||
|
||||
@@ -264,13 +270,38 @@ router.get('/:id/valorisations', requireInvestisseur, (req, res, next) => {
|
||||
router.post('/:id/valorisations', requireInvestisseur, (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
ownedDeal(req, id);
|
||||
const deal = ownedDeal(req, id);
|
||||
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(`
|
||||
INSERT INTO investissements_pe_valorisations (investissement_pe_id, date_valorisation, valorisation_nette, notes)
|
||||
VALUES (?,?,?,?)
|
||||
`).run(id, body.date_valorisation, body.valorisation_nette, body.notes || null);
|
||||
res.status(201).json({ id: r.lastInsertRowid, investissement_pe_id: id, ...body });
|
||||
INSERT INTO investissements_pe_valorisations
|
||||
(investissement_pe_id, date_valorisation, valorisation_nette, capital_deploye, plus_value_latente, notes)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
`).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); }
|
||||
});
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ const FRAIS_MODE_LABELS = {
|
||||
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: '' };
|
||||
|
||||
export default function InvestissementPeDetail() {
|
||||
@@ -243,9 +243,21 @@ export default function InvestissementPeDetail() {
|
||||
const submitVal = async (e) => {
|
||||
e?.preventDefault?.(); setValErr(null);
|
||||
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`, {
|
||||
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,
|
||||
});
|
||||
setValForm(emptyValForm);
|
||||
@@ -381,6 +393,36 @@ export default function InvestissementPeDetail() {
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className="topbar">
|
||||
@@ -400,8 +442,6 @@ export default function InvestissementPeDetail() {
|
||||
<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></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 style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtMultiple(deal.tvpi)}</div></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 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 }}>
|
||||
{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 }}>
|
||||
{deal.plateforme_nom} — {deal.investisseur_nom} · Souscrit le {fmtDate(deal.date_souscription)}
|
||||
{deal.strategie ? ` · ${deal.strategie}` : ''}
|
||||
@@ -429,8 +509,28 @@ export default function InvestissementPeDetail() {
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<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' }}>
|
||||
<label>Notes</label>
|
||||
<input value={valForm.notes} onChange={e => setValForm({ ...valForm, notes: e.target.value })} />
|
||||
@@ -450,7 +550,14 @@ export default function InvestissementPeDetail() {
|
||||
{valorisations.map(v => (
|
||||
<tr key={v.id}>
|
||||
<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><button className="icon-btn" title="Supprimer" onClick={() => setValDeleteTarget(v)}>✕</button></td>
|
||||
</tr>
|
||||
|
||||
Reference in new issue
Block a user