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)');
|
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
|
||||||
|
|||||||
@@ -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); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user