This commit is contained in:
ocroguennec committed 2026-09-19 14:37:02 +02:00
1 parent 6bba34396c
commit 1d5737703b
9 files changed
+347 -45

No files matched your search

+27 -7
View File
@@ -144,13 +144,28 @@ router.get('/', (req, res) => {
// (Fundora, 31/08/26) où la plateforme débite le montant BRUT, pas seulement le net investi. // (Fundora, 31/08/26) où la plateforme débite le montant BRUT, pas seulement le net investi.
// 'portefeuille' est un versement complémentaire distinct de montant_investi, jamais câblé // 'portefeuille' est un versement complémentaire distinct de montant_investi, jamais câblé
// jusqu'ici. 'compte_courant' reste exclu (aucun impact sur le porte-monnaie). // jusqu'ici. 'compte_courant' reste exclu (aucun impact sur le porte-monnaie).
// Inclut désormais aussi les frais PE (investissement_pe_id) — jusqu'ici absents (LEFT JOIN
// ajouté le 19/09/26, bug trouvé par Claude en creusant le signalement Olivier du même jour
// sur le solde indicatif : une plateforme partagée crowdlending/PE voyait son solde_portefeuille
// légèrement surestimé dès qu'un frais PE "source"/"portefeuille" existait, ce frais n'étant
// jamais déduit nulle part). COALESCE sur investisseur_id : un frais n'a jamais les deux à la
// fois (CHECK en base), donc au plus un des deux LEFT JOIN matche réellement.
// Correctif du même jour (Olivier, après coup) : pour un deal PE, 'source' n'est PAS un débit
// supplémentaire — montant_investi (déjà déduit via capitalInvestiPePerPlat plus bas) est le
// montant BRUT transféré, frais compris. Ne compter un frais PE que s'il est 'portefeuille'
// (versement complémentaire, réellement distinct) ; côté crowdlending, 'source' reste compté
// (montant_investi y est net — cf. cas Fundora du 31/08/26, modèle différent de PE).
const fraisPerPlat = db.prepare(` const fraisPerPlat = db.prepare(`
SELECT f.plateforme_id, SELECT f.plateforme_id,
COALESCE(SUM(f.montant), 0) AS total_frais COALESCE(SUM(f.montant), 0) AS total_frais
FROM frais_operations f FROM frais_operations f
JOIN investissements i ON i.id = f.investissement_id LEFT JOIN investissements i ON i.id = f.investissement_id
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')} LEFT JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
AND f.mode_reglement IN ('source', 'portefeuille') WHERE ${invWhere.replace('investisseur_id', 'COALESCE(i.investisseur_id, ipe.investisseur_id)')}
AND (
(i.id IS NOT NULL AND f.mode_reglement IN ('source', 'portefeuille'))
OR (ipe.id IS NOT NULL AND f.mode_reglement = 'portefeuille')
)
${asOf ? 'AND f.date_operation <= ?' : ''} ${asOf ? 'AND f.date_operation <= ?' : ''}
GROUP BY f.plateforme_id GROUP BY f.plateforme_id
`).all(...invParams, ...(asOf ? [asOf] : [])); `).all(...invParams, ...(asOf ? [asOf] : []));
@@ -381,13 +396,18 @@ router.get('/solde-historique', (req, res) => {
`).get(platId, ...invParams, date); `).get(platId, ...invParams, date);
/* ── 5. Frais d'opération 'source'/'portefeuille' (jusqu'à :date) ─── */ /* ── 5. Frais d'opération 'source'/'portefeuille' (jusqu'à :date) ─── */
// Cf. GET / ci-dessus (fraisPerPlat) — même logique, même exclusion de compte_courant. // Cf. GET / ci-dessus (fraisPerPlat) — même logique, même exclusion de compte_courant, même
// exclusion des frais PE 'source' (déjà inclus dans montant_investi, cf. commentaire ci-dessus).
const frais = db.prepare(` const frais = db.prepare(`
SELECT COALESCE(SUM(f.montant), 0) AS total_frais SELECT COALESCE(SUM(f.montant), 0) AS total_frais
FROM frais_operations f FROM frais_operations f
JOIN investissements i ON i.id = f.investissement_id LEFT JOIN investissements i ON i.id = f.investissement_id
WHERE i.plateforme_id = ? AND ${invWhere.replace('investisseur_id', 'i.investisseur_id')} LEFT JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
AND f.mode_reglement IN ('source', 'portefeuille') WHERE f.plateforme_id = ? AND ${invWhere.replace('investisseur_id', 'COALESCE(i.investisseur_id, ipe.investisseur_id)')}
AND (
(i.id IS NOT NULL AND f.mode_reglement IN ('source', 'portefeuille'))
OR (ipe.id IS NOT NULL AND f.mode_reglement = 'portefeuille')
)
AND f.date_operation <= ? AND f.date_operation <= ?
`).get(platId, ...invParams, date); `).get(platId, ...invParams, date);
+14 -3
View File
@@ -172,13 +172,24 @@ router.get('/', (req, res) => {
// Frais d'opération et corrections de solde — concepts crowdlending // Frais d'opération et corrections de solde — concepts crowdlending
// uniquement (cf. GET /api/dashboard), mais réduisent/ajustent le même // uniquement (cf. GET /api/dashboard), mais réduisent/ajustent le même
// porte-monnaie physique sur une plateforme partagée. // porte-monnaie physique sur une plateforme partagée.
// Même correctif que dashboard.js (19/09/26) : le JOIN ne portait que sur les investissements
// crowdlending, donc les frais PE (investissement_pe_id) n'étaient jamais déduits du solde
// porte-monnaie — surestimation silencieuse sur toute plateforme partagée avec des frais PE
// "source"/"portefeuille". LEFT JOIN + COALESCE des deux investisseur_id possibles.
// Correctif du même jour (Olivier, après coup) : en fait seul 'portefeuille' doit compter côté
// PE — 'source' est déjà inclus dans montant_investi (le montant BRUT transféré à la
// souscription), contrairement au crowdlending où montant_investi est net (cf. dashboard.js).
const fraisPerPlat = db.prepare(` const fraisPerPlat = db.prepare(`
SELECT f.plateforme_id, SELECT f.plateforme_id,
COALESCE(SUM(f.montant), 0) AS total_frais COALESCE(SUM(f.montant), 0) AS total_frais
FROM frais_operations f FROM frais_operations f
JOIN investissements i ON i.id = f.investissement_id LEFT JOIN investissements i ON i.id = f.investissement_id
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')} LEFT JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
AND f.mode_reglement IN ('source', 'portefeuille') WHERE ${invWhere.replace('investisseur_id', 'COALESCE(i.investisseur_id, ipe.investisseur_id)')}
AND (
(i.id IS NOT NULL AND f.mode_reglement IN ('source', 'portefeuille'))
OR (ipe.id IS NOT NULL AND f.mode_reglement = 'portefeuille')
)
${asOf ? 'AND f.date_operation <= ?' : ''} ${asOf ? 'AND f.date_operation <= ?' : ''}
GROUP BY f.plateforme_id GROUP BY f.plateforme_id
`).all(...invParams, ...(asOf ? [asOf] : [])); `).all(...invParams, ...(asOf ? [asOf] : []));
+153
View File
@@ -181,6 +181,159 @@ router.get('/valorisations', (req, res) => {
res.json(rows); res.json(rows);
}); });
/**
* GET /api/investissements-pe/mouvements-portefeuille — 19/09/26, demande Olivier : "le suivi
* de mouvement du porte-monnaie doit être commun [crowdlending + PE], avec la possibilité de
* distinguer si l'on parle de crowdlending ou de PE".
*
* Contexte du bug signalé : l'onglet "Mouvements Porte-monnaie" (Plateformes.jsx, calcul du
* solde indicatif ligne à ligne via MouvementsShared.computeSoldeIndicatifMap) ne consomme que
* des endpoints bornés au workspace actif (GET /remboursements, /depots-retraits,
* /frais-operations, /investissements) — cohérent pour séparer la performance crowdlending de
* la performance PE, mais faux pour le porte-monnaie : sur une plateforme partagée (ex.
* Fundora), l'argent est UN SEUL porte-monnaie physique chez le courtier, alimenté ou ponctionné
* indifféremment par les deux activités. Un remboursement PE réutilisé pour souscrire un deal
* crowdlending devient alors invisible du côté crowdlending, et le cumul plonge artificiellement
* dans le négatif après chaque retrait — exactement le symptôme observé sur Fundora (3,45 €).
*
* Même principe déjà retenu pour solde_portefeuille (dashboard.js / dashboardPe.js, walletMap,
* 17/09/26) : cet endpoint-ci fournit la partie du porte-monnaie qui vit HORS du workspace
* actuellement actif (typiquement PE, quand appelé depuis Plateformes.jsx en workspace
* crowdlending), pour que le frontend la fusionne avec ce qu'il a déjà côté workspace actif —
* sans double-compte, et sans avoir à assouplir le scope workspace_id des endpoints existants.
* Quatre familles de lignes, déjà mises en forme proche de MouvementsShared (type/montant/
* date_operation/plateforme_id/libelle) pour limiter le travail de fusion côté frontend :
* - souscription : capital déployé à la souscription de chaque deal PE (montant_investi),
* pas encore modélisé comme mouvement ailleurs (pendant PE de la ligne "souscription"
* synthétique que Plateformes.jsx génère déjà pour le crowdlending).
* - interets_plateforme : distributions PE (remboursements.type='interets_plateforme'),
* créditées pour leur net_recu — même convention que interetsPlateformeWalletPerPlat dans
* dashboardPe.js ("net_recu tient déjà compte des prélèvements").
* - frais : frais_operations rattachés à investissement_pe_id, jusqu'ici absents aussi du
* calcul officiel du solde (cf. correctif fraisPerPlat du même jour dans dashboard*.js).
* - depot / retrait : dépôts/retraits manuels (table depots_retraits) enregistrés alors que
* le workspace PE était actif — AJOUTÉ le 19/09/26 suite au signalement Olivier sur Fundora,
* où 4 dépôts manuels de 800 € (un par deal PE) étaient invisibles du porte-monnaie
* crowdlending, expliquant l'essentiel du solde indicatif faussé (bien plus que les 3,45 €
* de distribution initialement en cause). Filtrées sur workspace_id != workspace appelant
* (résolu comme à l'écriture, cf. resolveActiveWorkspaceId) plutôt que sur un id "PE" en
* dur, pour rester correct si un 3e workspace apparaît un jour. `raw_source` porte la valeur
* brute de depots_retraits.source (manuel/import_excel/auto_remboursement) pour que le
* frontend puisse reconstituer le badge "AUTO" comme pour les dépôts crowdlending.
*
* corrections_solde n'a PAS de colonne workspace_id (vérifié en base) : aucune correction ne
* peut donc être "perdue" par ce bug, ce n'était pas un vrai gap malgré ce qu'un premier passage
* avait supposé par prudence.
*/
router.get('/mouvements-portefeuille', (req, res) => {
const scopeAll = req.query.scope === 'all';
const userId = req.user.id;
let invCond, invArgs, invCondDr, invArgsDr;
if (scopeAll) {
invCond = 'inv.user_id = ?';
invArgs = [userId];
invCondDr = 'dr.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)';
invArgsDr = [userId];
} else {
const raw = req.header('X-Investisseur-Id');
const id = Number(raw);
if (!id) return res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' });
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(id, userId);
if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' });
invCond = 'ipe.investisseur_id = ?';
invArgs = [id];
invCondDr = 'dr.investisseur_id = ?';
invArgsDr = [id];
}
// Volontairement PAS de filtre workspace_id sur souscriptions/distributions/frais (tables
// dédiées PE) : ce sont par construction des lignes qui vivent hors du workspace actif
// (crowdlending) qui appelle cet endpoint. Pour depots_retraits en revanche (table partagée
// entre workspaces), il FAUT exclure explicitement le workspace appelant, pour ne pas
// dupliquer ce que le frontend a déjà récupéré via son propre GET /depots-retraits scopé.
const callerWorkspaceId = resolveActiveWorkspaceId(req);
const souscriptions = db.prepare(`
SELECT ipe.id AS deal_id, ipe.plateforme_id, p.nom AS plateforme_nom,
plat_inv.nom AS plateforme_detenteur_nom,
ipe.date_souscription AS date_operation, ipe.montant_investi AS montant,
ipe.nom_deal AS libelle, ipe.investisseur_id, inv.nom AS investisseur_nom
FROM investissements_pe ipe
JOIN plateformes p ON p.id = ipe.plateforme_id
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond}
`).all(...invArgs);
const distributions = db.prepare(`
SELECT r.id AS remb_id, ipe.plateforme_id, p.nom AS plateforme_nom,
plat_inv.nom AS plateforme_detenteur_nom,
r.date_remb AS date_operation, r.net_recu AS montant,
ipe.nom_deal AS libelle, ipe.investisseur_id, inv.nom AS investisseur_nom
FROM remboursements r
JOIN investissements_pe ipe ON ipe.id = r.investissement_pe_id
JOIN plateformes p ON p.id = ipe.plateforme_id
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE r.type = 'interets_plateforme' AND ${invCond}
`).all(...invArgs);
const frais = db.prepare(`
SELECT f.id AS frais_id, f.plateforme_id, p.nom AS plateforme_nom,
plat_inv.nom AS plateforme_detenteur_nom,
f.date_operation, f.montant, f.mode_reglement,
ipe.nom_deal AS libelle, ipe.investisseur_id, inv.nom AS investisseur_nom
FROM frais_operations f
JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
JOIN plateformes p ON p.id = f.plateforme_id
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond}
`).all(...invArgs);
const depots = db.prepare(`
SELECT dr.id AS dr_id, dr.plateforme_id, p.nom AS plateforme_nom,
plat_inv.nom AS plateforme_detenteur_nom,
dr.date_operation, dr.type, dr.montant, dr.source AS raw_source,
dr.libelle, dr.investisseur_id, inv.nom AS investisseur_nom
FROM depots_retraits dr
JOIN plateformes p ON p.id = dr.plateforme_id
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
JOIN investisseurs inv ON inv.id = dr.investisseur_id
WHERE dr.workspace_id != ? AND ${invCondDr}
`).all(callerWorkspaceId, ...invArgsDr);
const rows = [
...souscriptions.map(r => ({
id: `pe_sub_${r.deal_id}`, type: 'souscription', montant: r.montant,
date_operation: r.date_operation, plateforme_id: r.plateforme_id, plateforme_nom: r.plateforme_nom,
plateforme_detenteur_nom: r.plateforme_detenteur_nom, libelle: r.libelle,
investisseur_id: r.investisseur_id, investisseur_nom: r.investisseur_nom,
})),
...distributions.map(r => ({
id: `pe_remb_${r.remb_id}`, type: 'interets_plateforme', montant: r.montant,
date_operation: r.date_operation, plateforme_id: r.plateforme_id, plateforme_nom: r.plateforme_nom,
plateforme_detenteur_nom: r.plateforme_detenteur_nom, libelle: r.libelle,
investisseur_id: r.investisseur_id, investisseur_nom: r.investisseur_nom,
})),
...frais.map(r => ({
id: `pe_frais_${r.frais_id}`, type: 'frais', montant: r.montant, mode_reglement: r.mode_reglement,
date_operation: r.date_operation, plateforme_id: r.plateforme_id, plateforme_nom: r.plateforme_nom,
plateforme_detenteur_nom: r.plateforme_detenteur_nom, libelle: r.libelle,
investisseur_id: r.investisseur_id, investisseur_nom: r.investisseur_nom,
})),
...depots.map(r => ({
id: `pe_dr_${r.dr_id}`, type: r.type, montant: r.montant, raw_source: r.raw_source,
date_operation: r.date_operation, plateforme_id: r.plateforme_id, plateforme_nom: r.plateforme_nom,
plateforme_detenteur_nom: r.plateforme_detenteur_nom, libelle: r.libelle,
investisseur_id: r.investisseur_id, investisseur_nom: r.investisseur_nom,
})),
];
res.json(rows);
});
router.post('/', requireInvestisseur, (req, res, next) => { router.post('/', requireInvestisseur, (req, res, next) => {
try { try {
const body = Schema.parse(req.body); const body = Schema.parse(req.body);
+12 -1
View File
@@ -33,6 +33,16 @@ import Modal from './Modal.jsx';
* InvestissementDetail.jsx, investissement déjà connu du contexte) * InvestissementDetail.jsx, investissement déjà connu du contexte)
* investissementOptions — liste pour le <select> investissement (mode non-locked * investissementOptions — liste pour le <select> investissement (mode non-locked
* uniquement) — { id, plateforme_nom, nom_projet }[] * uniquement) — { id, plateforme_nom, nom_projet }[]
* pe — bool (19/09/26, demande Olivier) : true quand ce frais
* est rattaché à un deal Private Equity plutôt qu'à un
* investissement crowdlending. Ne change que le libellé de
* l'option "source" du sélecteur Prélèvement ci-dessous —
* côté PE, ce mode réduit le capital investissable du deal
* (montant_investi reste le montant BRUT transféré, cf.
* InvestissementPeFormModal.jsx / capital_investissable),
* alors que côté crowdlending il s'ajoute au capital net
* déjà déduit à la souscription — deux mécaniques
* différentes qui méritaient des libellés différents.
* confirmingDelete, setConfirmingDelete — état de confirmation de suppression (géré par le * confirmingDelete, setConfirmingDelete — état de confirmation de suppression (géré par le
* parent, remis à false dans son propre onClose) * parent, remis à false dans son propre onClose)
*/ */
@@ -45,6 +55,7 @@ export default function FraisFormModal({
investissementOptions = [], investissementOptions = [],
confirmingDelete, setConfirmingDelete, confirmingDelete, setConfirmingDelete,
submitting = false, submitting = false,
pe = false,
}) { }) {
const modalTitle = editingId ? 'Modifier le frais' : 'Ajouter un frais'; const modalTitle = editingId ? 'Modifier le frais' : 'Ajouter un frais';
@@ -118,7 +129,7 @@ export default function FraisFormModal({
<label>Prélèvement</label> <label>Prélèvement</label>
<select value={form.mode_reglement} <select value={form.mode_reglement}
onChange={e => onFieldChange('mode_reglement', e.target.value)}> onChange={e => onFieldChange('mode_reglement', e.target.value)}>
<option value="source">À la source (sans mouvement complémentaire)</option> <option value="source">{pe ? 'Lors de la souscription (diminue le montant investissable)' : 'À la source (sans mouvement complémentaire)'}</option>
<option value="portefeuille">Versement complémentaire — Porte-monnaie de la plateforme</option> <option value="portefeuille">Versement complémentaire — Porte-monnaie de la plateforme</option>
<option value="compte_courant">Versement complémentaire — Compte courant du détenteur</option> <option value="compte_courant">Versement complémentaire — Compte courant du détenteur</option>
</select> </select>
@@ -218,7 +218,7 @@ function FraisBlock({ prefix, label, form, setField, handleFraisReglementChange,
<div style={{ gridColumn: '1 / -1' }}> <div style={{ gridColumn: '1 / -1' }}>
<label>Prélèvement</label> <label>Prélèvement</label>
<select value={modeReglement} onChange={e => handleFraisReglementChange(prefix, e.target.value)}> <select value={modeReglement} onChange={e => handleFraisReglementChange(prefix, e.target.value)}>
<option value="source">À la source (sans mouvement complémentaire)</option> <option value="source">Lors de la souscription (diminue le montant investissable)</option>
<option value="portefeuille">Versement complémentaire — Porte-monnaie de la plateforme</option> <option value="portefeuille">Versement complémentaire — Porte-monnaie de la plateforme</option>
<option value="compte_courant">Versement complémentaire — Compte courant du détenteur</option> <option value="compte_courant">Versement complémentaire — Compte courant du détenteur</option>
</select> </select>
+35 -6
View File
@@ -41,7 +41,20 @@ export function signedMontant(r) {
// retomber sur interets_nets — le frais doit suivre exactement la même convention, sinon la // retomber sur interets_nets — le frais doit suivre exactement la même convention, sinon la
// chaîne ne retombe jamais à 0 après un retrait AUTO (confirmé par Olivier le 01/09/26 : le // chaîne ne retombe jamais à 0 après un retrait AUTO (confirmé par Olivier le 01/09/26 : le
// reliquat observé était exactement égal au montant du frais). // reliquat observé était exactement égal au montant du frais).
if (r.type === 'frais') return r.mode_reglement === 'compte_courant' ? 0 : -Math.abs(r.montant); // Frais PE prélevés "à la source" (19/09/26, confirmé par Olivier) : contrairement au
// crowdlending où montant_investi est le capital NET (frais 'source' = débit supplémentaire
// légitime, cf. commentaire ci-dessus), côté PE le montant_investi affiché en ligne
// "souscription" est déjà le montant BRUT transféré à la plateforme, frais compris — un
// frais 'source' PE n'est donc PAS un débit supplémentaire du porte-monnaie, sous peine de
// compter deux fois la même sortie d'argent. Affiché quand même (traçabilité), à 0 d'effet,
// comme 'compte_courant'. Repéré via r._investType === 'pe' (posé par Plateformes.jsx /
// mvNormalizedPeMouvements) plutôt que par plateforme : ne dépend pas de la plateforme, mais
// du modèle de données PE lui-même (toujours vrai pour tout deal PE).
if (r.type === 'frais') {
if (r.mode_reglement === 'compte_courant') return 0;
if (r._investType === 'pe' && r.mode_reglement === 'source') return 0;
return -Math.abs(r.montant);
}
if (DEBIT_TYPES.has(r.type)) return -Math.abs(r.montant); if (DEBIT_TYPES.has(r.type)) return -Math.abs(r.montant);
if (r.type === 'correction') return r.montant; if (r.type === 'correction') return r.montant;
return Math.abs(r.montant); return Math.abs(r.montant);
@@ -464,6 +477,16 @@ export function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onVie
const isFraisHorsPM = isFrais && row.mode_reglement === 'compte_courant'; const isFraisHorsPM = isFrais && row.mode_reglement === 'compte_courant';
const isFraisRembLinked = isFrais && row.mode_reglement === 'remboursement'; const isFraisRembLinked = isFrais && row.mode_reglement === 'remboursement';
const isAuto = row.source === 'auto_remboursement'; const isAuto = row.source === 'auto_remboursement';
// Mouvement PE fusionné dans le porte-monnaie crowdlending (19/09/26, cf. Plateformes.jsx /
// mvNormalizedPeMouvements) : les liens "Voir l'investissement" / "Voir le remboursement" /
// "Modifier" ci-dessous supposent tous des ids et des routes du workspace crowdlending
// (/investissements/:id, /remboursements?edit-remb=, le modal dépôt/retrait crowdlending) —
// inapplicables tels quels à un id de la table PE ou à un dépôt enregistré sous un autre
// workspace_id. Plutôt que de risquer une navigation vers le mauvais enregistrement (voire,
// en cas de collision d'id entre investissements et investissements_pe, vers un investissement
// crowdlending sans rapport), on affiche ici une mention neutre : à modifier depuis son
// workspace d'origine.
const isForeign = row._investType === 'pe';
const typeLabel = isCorrection ? 'Correction de solde' const typeLabel = isCorrection ? 'Correction de solde'
: isBonus ? 'Bonus' : isBonus ? 'Bonus'
@@ -638,10 +661,12 @@ export function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onVie
? "Frais réglé via un compte courant du détenteur — n'a jamais d'effet sur le solde du porte-monnaie de la plateforme, affiché ici à titre de traçabilité uniquement." ? "Frais réglé via un compte courant du détenteur — n'a jamais d'effet sur le solde du porte-monnaie de la plateforme, affiché ici à titre de traçabilité uniquement."
: isFraisRembLinked : isFraisRembLinked
? "Frais de distribution déduit du coupon brut de ce remboursement, avant calcul des prélèvements — réduit d'autant le montant net reçu (cf. avis d'opéré de la plateforme)." ? "Frais de distribution déduit du coupon brut de ce remboursement, avant calcul des prélèvements — réduit d'autant le montant net reçu (cf. avis d'opéré de la plateforme)."
: row.mode_reglement === 'source' : (isForeign && row.mode_reglement === 'source')
? "Frais prélevé à la source par la plateforme sur le montant transféré — sort du porte-monnaie en plus du capital net déjà déduit de la souscription." ? "Frais prélevé à la source par la plateforme sur le montant transféré lors de la souscription au deal — déjà inclus dans le montant de la souscription, sans effet supplémentaire sur le solde du porte-monnaie."
: "Versement complémentaire réglé depuis le porte-monnaie de la plateforme — sort du porte-monnaie indépendamment du capital investi."} : row.mode_reglement === 'source'
{' '}{isFraisRembLinked ? "Modifiable depuis le remboursement." : "Modifiable depuis la fiche de l'investissement."} ? "Frais prélevé à la source par la plateforme sur le montant transféré — sort du porte-monnaie en plus du capital net déjà déduit de la souscription."
: "Versement complémentaire réglé depuis le porte-monnaie de la plateforme — sort du porte-monnaie indépendamment du capital investi."}
{' '}{isForeign ? null : isFraisRembLinked ? "Modifiable depuis le remboursement." : "Modifiable depuis la fiche de l'investissement."}
</span> </span>
</div> </div>
)} )}
@@ -656,7 +681,11 @@ export function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onVie
</div> </div>
<div className="dr-detail-footer"> <div className="dr-detail-footer">
{isAuto ? null : (isRemb || isPrelevement) ? ( {isForeign ? (
<div style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)', fontStyle: 'italic', padding: '4px 2px' }}>
Mouvement Private Equity — modifiable depuis le workspace Private Equity.
</div>
) : isAuto ? null : (isRemb || isPrelevement) ? (
<button className="dr-detail-edit-btn" onClick={() => onViewRemb(row)}> <button className="dr-detail-edit-btn" onClick={() => onViewRemb(row)}>
Voir / modifier le remboursement Voir / modifier le remboursement
</button> </button>
+1
View File
@@ -559,6 +559,7 @@ export default function FraisPe() {
investissementOptions={investissementOptions} investissementOptions={investissementOptions}
confirmingDelete={confirmingDelete} confirmingDelete={confirmingDelete}
setConfirmingDelete={setConfirmingDelete} setConfirmingDelete={setConfirmingDelete}
pe
/> />
</> </>
); );
@@ -1015,6 +1015,7 @@ export default function InvestissementPeDetail() {
submitting={fraisSubmitting} submitting={fraisSubmitting}
comptesInvestisseur={comptesInvestisseur} comptesInvestisseur={comptesInvestisseur}
locked locked
pe
investissementOptions={[]} investissementOptions={[]}
confirmingDelete={fraisConfirmingDelete} confirmingDelete={fraisConfirmingDelete}
setConfirmingDelete={setFraisConfirmingDelete} setConfirmingDelete={setFraisConfirmingDelete}
+103 -27
View File
@@ -306,6 +306,12 @@ export default function Plateformes() {
const [allDepots, setAllDepots] = useState([]); const [allDepots, setAllDepots] = useState([]);
const [allCorrections, setAllCorrections] = useState([]); const [allCorrections, setAllCorrections] = useState([]);
const [allFraisOps, setAllFraisOps] = useState([]); // frais_operations, tous investisseurs (filtré via mvInvById) const [allFraisOps, setAllFraisOps] = useState([]); // frais_operations, tous investisseurs (filtré via mvInvById)
// Mouvements porte-monnaie PE (souscriptions/distributions/frais) vivant hors du workspace
// crowdlending actif — cf. GET /investissements-pe/mouvements-portefeuille. Une plateforme
// (ex. Fundora) peut faire du crowdlending ET du PE avec un porte-monnaie physique unique ;
// sans ça, le solde indicatif de l'onglet "Mouvements Porte-monnaie" ignorait tout l'argent
// côté PE (bug signalé par Olivier le 19/09/26).
const [allPeWalletMouvements, setAllPeWalletMouvements] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// Snapshots backend du solde porte-monnaie par plateforme, clé 'live' ou 'YYYY-12-31' // Snapshots backend du solde porte-monnaie par plateforme, clé 'live' ou 'YYYY-12-31'
// (GET /dashboard[?asOf=]) — remplace le recalcul client historique de // (GET /dashboard[?asOf=]) — remplace le recalcul client historique de
@@ -377,7 +383,7 @@ export default function Plateformes() {
setLoading(true); setLoading(true);
try { try {
const scopeParams = activeView === 'all' ? { scope: 'all' } : {}; const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
const [rows, p, rembs, reinvests, depots, corrections, simulRembs, pfu, fraisOps] = await Promise.all([ const [rows, p, rembs, reinvests, depots, corrections, simulRembs, pfu, fraisOps, peWalletMouvements] = await Promise.all([
api.get('/investissements', scopeParams), api.get('/investissements', scopeParams),
api.get('/plateformes'), api.get('/plateformes'),
api.get('/remboursements', scopeParams), api.get('/remboursements', scopeParams),
@@ -389,6 +395,10 @@ export default function Plateformes() {
// /frais-operations sans investissement_id ne connaît lui aussi que l'utilisateur — // /frais-operations sans investissement_id ne connaît lui aussi que l'utilisateur —
// même pattern que /reinvestissements, filtré côté client via mvInvById. // même pattern que /reinvestissements, filtré côté client via mvInvById.
api.get('/frais-operations').catch(() => []), api.get('/frais-operations').catch(() => []),
// Toujours hors scopeParams (scope=all/X-Investisseur-Id géré côté backend comme les
// autres endpoints ci-dessus) mais jamais borné au workspace actif, volontairement —
// cf. commentaire de la route.
api.get('/investissements-pe/mouvements-portefeuille', scopeParams).catch(() => []),
]); ]);
setAllRows(rows); setAllRows(rows);
setPlats(p); setPlats(p);
@@ -399,6 +409,7 @@ export default function Plateformes() {
setAllSimulRembs(Array.isArray(simulRembs) ? simulRembs : []); setAllSimulRembs(Array.isArray(simulRembs) ? simulRembs : []);
setPfuRates(Array.isArray(pfu) ? pfu : []); setPfuRates(Array.isArray(pfu) ? pfu : []);
setAllFraisOps(Array.isArray(fraisOps) ? fraisOps : []); setAllFraisOps(Array.isArray(fraisOps) ? fraisOps : []);
setAllPeWalletMouvements(Array.isArray(peWalletMouvements) ? peWalletMouvements : []);
} finally { setLoading(false); setDrillRefreshKey(k => k + 1); } } finally { setLoading(false); setDrillRefreshKey(k => k + 1); }
}; };
@@ -1078,7 +1089,7 @@ export default function Plateformes() {
allReinvests, allCorrections, plats). Toute évolution de la logique de allReinvests, allCorrections, plats). Toute évolution de la logique de
décomposition des mouvements doit être répercutée dans les deux fichiers. décomposition des mouvements doit être répercutée dans les deux fichiers.
══════════════════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════════════════ */
const [mvFilter, setMvFilter] = useState({ type: '', plateforme_ids: [], projet: '', year: '', month: '' }); const [mvFilter, setMvFilter] = useState({ type: '', investType: '', plateforme_ids: [], projet: '', year: '', month: '' });
const [mvListFocused, setMvListFocused] = useState(false); const [mvListFocused, setMvListFocused] = useState(false);
const [mvModalOpen, setMvModalOpen] = useState(false); const [mvModalOpen, setMvModalOpen] = useState(false);
const [mvEditingId, setMvEditingId] = useState(null); const [mvEditingId, setMvEditingId] = useState(null);
@@ -1327,6 +1338,32 @@ export default function Plateformes() {
return true; return true;
}), [mvNormalizedFraisMouvements, mvFilter.plateforme_ids, mvFilter.year, mvFilter.month]); }), [mvNormalizedFraisMouvements, mvFilter.plateforme_ids, mvFilter.year, mvFilter.month]);
// Mouvements PE (19/09/26, demande Olivier — porte-monnaie unifié) : déjà mis en forme côté
// backend (GET /investissements-pe/mouvements-portefeuille, type/montant/date_operation/
// plateforme_id/libelle prêts à l'emploi), on ajoute juste ici _investType (pour le filtre et
// le badge) et `source` (même convention que les lignes crowdlending, pour l'export).
// Depuis le 19/09/26, allPeWalletMouvements inclut aussi des lignes type='depot'/'retrait'
// (dépôts manuels enregistrés côté workspace PE, ex. les 4 x 800 € de Fundora) : on leur
// reconstitue un `source` cohérent avec les dépôts crowdlending (mvRows, via raw_source =
// valeur brute de depots_retraits.source) pour que le badge "AUTO" et l'export CSV/XLS
// restent corrects.
const mvNormalizedPeMouvements = useMemo(() => allPeWalletMouvements.map(r => ({
...r,
_investType: 'pe',
source: r.type === 'souscription' ? 'souscription'
: r.type === 'frais' ? 'frais_operation'
: (r.type === 'depot' || r.type === 'retrait') ? (r.raw_source || 'manuel')
: 'remboursement',
})), [allPeWalletMouvements]);
const mvFilteredPeMouvements = useMemo(() => mvNormalizedPeMouvements.filter(r => {
if (mvFilter.plateforme_ids.length > 0 && !mvFilter.plateforme_ids.includes(String(r.plateforme_id))) return false;
const [y, m] = r.date_operation.split('-');
if (mvFilter.year && y !== mvFilter.year) return false;
if (mvFilter.month && m !== mvFilter.month.padStart(2,'0')) return false;
return true;
}), [mvNormalizedPeMouvements, mvFilter.plateforme_ids, mvFilter.year, mvFilter.month]);
// Projets disponibles pour le filtre "Projet" — dépend de la plateforme (verrouillée) mais // Projets disponibles pour le filtre "Projet" — dépend de la plateforme (verrouillée) mais
// pas des autres filtres (type/année/mois), pour ne pas faire disparaître d'options en cours // pas des autres filtres (type/année/mois), pour ne pas faire disparaître d'options en cours
// d'affinage. Inclut les remboursements/souscriptions/réinvestissements ainsi que les // d'affinage. Inclut les remboursements/souscriptions/réinvestissements ainsi que les
@@ -1338,46 +1375,55 @@ export default function Plateformes() {
for (const r of mvNormalizedRembMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle); for (const r of mvNormalizedRembMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle);
for (const r of mvNormalizedSouscriptionMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle); for (const r of mvNormalizedSouscriptionMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle);
for (const r of mvNormalizedFraisMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle); for (const r of mvNormalizedFraisMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle);
for (const r of mvNormalizedPeMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle);
for (const d of allDepots) { for (const d of allDepots) {
if (!inScope(d.plateforme_id) || !d.remboursement_id) continue; if (!inScope(d.plateforme_id) || !d.remboursement_id) continue;
const p = mvRembProjetById.get(d.remboursement_id); const p = mvRembProjetById.get(d.remboursement_id);
if (p) set.add(p); if (p) set.add(p);
} }
return [...set].sort((a, b) => a.localeCompare(b, 'fr')); return [...set].sort((a, b) => a.localeCompare(b, 'fr'));
}, [mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements, mvNormalizedFraisMouvements, allDepots, mvRembProjetById, mvFilter.plateforme_ids]); }, [mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements, mvNormalizedFraisMouvements, mvNormalizedPeMouvements, allDepots, mvRembProjetById, mvFilter.plateforme_ids]);
// Solde indicatif du porte-monnaie après chaque mouvement — calculé sur l'historique // Solde indicatif du porte-monnaie après chaque mouvement — calculé sur l'historique
// COMPLET et NON FILTRÉ (allDepots/mvNormalizedCorrections/mvNormalizedRembMouvements/ // COMPLET et NON FILTRÉ (allDepots/mvNormalizedCorrections/mvNormalizedRembMouvements/
// mvNormalizedSouscriptionMouvements), pas sur les variantes déjà filtrées année/mois/ // mvNormalizedSouscriptionMouvements/mvNormalizedFraisMouvements/mvNormalizedPeMouvements),
// type/projet — sinon le cumul démarrerait arbitrairement au milieu de l'historique. // pas sur les variantes déjà filtrées année/mois/type/projet/investType — sinon le cumul
// démarrerait arbitrairement au milieu de l'historique. mvNormalizedPeMouvements est inclus
// ici (19/09/26, demande Olivier) même s'il n'a pas d'onglet dédié pour l'afficher seul : sur
// une plateforme partagée, ces lignes PE font partie du MÊME porte-monnaie physique, donc du
// même cumul, qu'on les affiche ou non selon le filtre "Type d'investissement" du moment.
// Purement indicatif (affichage/export) — ne remplace pas computeSoldePortefeuille. // Purement indicatif (affichage/export) — ne remplace pas computeSoldePortefeuille.
// Voir [[project_mouvements_solde_indicatif]].
const mvSoldeIndicatifByRowId = useMemo(() => computeSoldeIndicatifMap([ const mvSoldeIndicatifByRowId = useMemo(() => computeSoldeIndicatifMap([
...allDepots, ...mvNormalizedCorrections, ...mvNormalizedRembMouvements, ...mvNormalizedSouscriptionMouvements, ...allDepots, ...mvNormalizedCorrections, ...mvNormalizedRembMouvements, ...mvNormalizedSouscriptionMouvements,
...mvNormalizedFraisMouvements, ...mvNormalizedFraisMouvements, ...mvNormalizedPeMouvements,
]), [allDepots, mvNormalizedCorrections, mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements, mvNormalizedFraisMouvements]); ]), [allDepots, mvNormalizedCorrections, mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements, mvNormalizedFraisMouvements, mvNormalizedPeMouvements]);
const mvTableRows = useMemo(() => { const mvTableRows = useMemo(() => {
const showDR = !mvFilter.type || mvFilter.type === 'depot' || mvFilter.type === 'retrait'; const showDR = !mvFilter.type || mvFilter.type === 'depot' || mvFilter.type === 'retrait';
const showCorr = !mvFilter.type || mvFilter.type === 'correction'; const showCorr = !mvFilter.type || mvFilter.type === 'correction';
const drPart = showDR const showCl = mvFilter.investType !== 'pe';
? mvRows.map(r => ({ ...r, _projet: r.remboursement_id ? (mvRembProjetById.get(r.remboursement_id) || '') : '' })) const showPe = mvFilter.investType !== 'crowdlending';
const drPart = showDR && showCl
? mvRows.map(r => ({ ...r, _projet: r.remboursement_id ? (mvRembProjetById.get(r.remboursement_id) || '') : '', _investType: 'crowdlending' }))
: []; : [];
const corrPart = showCorr ? mvFilteredCorrections.map(c => ({ ...c, _projet: '' })) : []; const corrPart = showCorr && showCl ? mvFilteredCorrections.map(c => ({ ...c, _projet: '', _investType: 'crowdlending' })) : [];
const rembPart = mvFilteredRembMouvements const rembPart = showCl ? mvFilteredRembMouvements
.filter(r => !mvFilter.type || r.type === mvFilter.type) .filter(r => !mvFilter.type || r.type === mvFilter.type)
.map(r => ({ ...r, _projet: r.libelle || '' })); .map(r => ({ ...r, _projet: r.libelle || '', _investType: 'crowdlending' })) : [];
const subPart = mvFilteredSouscriptionMouvements const subPart = showCl ? mvFilteredSouscriptionMouvements
.filter(r => !mvFilter.type || r.type === mvFilter.type) .filter(r => !mvFilter.type || r.type === mvFilter.type)
.map(r => ({ ...r, _projet: r.libelle || '' })); .map(r => ({ ...r, _projet: r.libelle || '', _investType: 'crowdlending' })) : [];
const fraisPart = mvFilteredFraisMouvements const fraisPart = showCl ? mvFilteredFraisMouvements
.filter(r => !mvFilter.type || r.type === mvFilter.type) .filter(r => !mvFilter.type || r.type === mvFilter.type)
.map(r => ({ ...r, _projet: r.libelle || '' })); .map(r => ({ ...r, _projet: r.libelle || '', _investType: 'crowdlending' })) : [];
const pePart = showPe ? mvFilteredPeMouvements
.filter(r => !mvFilter.type || r.type === mvFilter.type)
.map(r => ({ ...r, _projet: r.libelle || '' })) : [];
return sortMouvements( return sortMouvements(
[...drPart, ...corrPart, ...rembPart, ...subPart, ...fraisPart] [...drPart, ...corrPart, ...rembPart, ...subPart, ...fraisPart, ...pePart]
.filter(r => !mvFilter.projet || r._projet === mvFilter.projet) .filter(r => !mvFilter.projet || r._projet === mvFilter.projet)
).map(r => ({ ...r, _soldeIndicatif: mvSoldeIndicatifByRowId.get(r.id) })); ).map(r => ({ ...r, _soldeIndicatif: mvSoldeIndicatifByRowId.get(r.id) }));
}, [mvRows, mvFilteredCorrections, mvFilteredRembMouvements, mvFilteredSouscriptionMouvements, mvFilteredFraisMouvements, mvFilter.type, mvFilter.projet, mvRembProjetById, mvSoldeIndicatifByRowId]); }, [mvRows, mvFilteredCorrections, mvFilteredRembMouvements, mvFilteredSouscriptionMouvements, mvFilteredFraisMouvements, mvFilteredPeMouvements, mvFilter.type, mvFilter.projet, mvFilter.investType, mvRembProjetById, mvSoldeIndicatifByRowId]);
const { const {
pagedItems: mvPagedTableRows, page: mvPage, setPage: setMvPage, pagedItems: mvPagedTableRows, page: mvPage, setPage: setMvPage,
@@ -2759,6 +2805,14 @@ export default function Plateformes() {
<option value="prelevement_local">Prélèvements étranger</option> <option value="prelevement_local">Prélèvements étranger</option>
</select> </select>
</div> </div>
<div style={{ flex: 1, minWidth: 130 }}>
<label>Type d'investissement</label>
<select value={mvFilter.investType} onChange={e => setMvFilter(f => ({ ...f, investType: e.target.value }))}>
<option value="">Tous</option>
<option value="crowdlending">Crowdlending</option>
<option value="pe">Private Equity</option>
</select>
</div>
<div style={{ flex: 2, minWidth: 160 }}> <div style={{ flex: 2, minWidth: 160 }}>
<label>Plateforme</label> <label>Plateforme</label>
<div style={{ <div style={{
@@ -2866,7 +2920,7 @@ export default function Plateformes() {
color: (isBonus || isCashback) ? 'var(--warning)' : isInteretsPlateforme ? 'var(--primary)' : 'var(--success)', color: (isBonus || isCashback) ? 'var(--warning)' : isInteretsPlateforme ? 'var(--primary)' : 'var(--success)',
}}>{isBonus ? 'bonus' : isCashback ? 'cashback' : isInteretsPlateforme ? 'intérêts plateforme' : 'remboursement'}</span> }}>{isBonus ? 'bonus' : isCashback ? 'cashback' : isInteretsPlateforme ? 'intérêts plateforme' : 'remboursement'}</span>
</td> </td>
<td className="text-muted">{r._projet || '—'}</td> <td className="text-muted">{r._investType === 'pe' && <span style={{ display: 'inline-block', fontSize: 10, fontWeight: 700, letterSpacing: '0.03em', padding: '1px 5px', borderRadius: 4, marginRight: 6, background: 'color-mix(in srgb, var(--primary) 15%, transparent)', color: 'var(--primary)' }} title="Mouvement Private Equity">PE</span>}{r._projet || '—'}</td>
<td className="num success"> <td className="num success">
+ {fmtEUR(r.montant)} + {fmtEUR(r.montant)}
</td> </td>
@@ -2896,7 +2950,7 @@ export default function Plateformes() {
color, color,
}}>{label}</span> }}>{label}</span>
</td> </td>
<td className="text-muted">{r._projet || '—'}</td> <td className="text-muted">{r._investType === 'pe' && <span style={{ display: 'inline-block', fontSize: 10, fontWeight: 700, letterSpacing: '0.03em', padding: '1px 5px', borderRadius: 4, marginRight: 6, background: 'color-mix(in srgb, var(--primary) 15%, transparent)', color: 'var(--primary)' }} title="Mouvement Private Equity">PE</span>}{r._projet || '—'}</td>
<td className="num danger"> <td className="num danger">
− {fmtEUR(r.montant)} − {fmtEUR(r.montant)}
</td> </td>
@@ -2933,7 +2987,7 @@ export default function Plateformes() {
}}>RÉINVEST.</span> }}>RÉINVEST.</span>
)} )}
</td> </td>
<td className="text-muted">{r._projet || '—'}</td> <td className="text-muted">{r._investType === 'pe' && <span style={{ display: 'inline-block', fontSize: 10, fontWeight: 700, letterSpacing: '0.03em', padding: '1px 5px', borderRadius: 4, marginRight: 6, background: 'color-mix(in srgb, var(--primary) 15%, transparent)', color: 'var(--primary)' }} title="Mouvement Private Equity">PE</span>}{r._projet || '—'}</td>
<td className="num danger"> <td className="num danger">
− {fmtEUR(r.montant)} − {fmtEUR(r.montant)}
</td> </td>
@@ -2945,11 +2999,17 @@ export default function Plateformes() {
if (r.type === 'frais') { if (r.type === 'frais') {
const horsPM = r.mode_reglement === 'compte_courant'; const horsPM = r.mode_reglement === 'compte_courant';
const rembLinked = r.mode_reglement === 'remboursement'; const rembLinked = r.mode_reglement === 'remboursement';
// Frais PE "à la source" (19/09/26) : déjà inclus dans le montant de la
// souscription (-800 € par ex.) — pas un débit supplémentaire du
// porte-monnaie. Même traitement visuel que HORS PM/DÉDUIT REMB. (grisé,
// badge, 0 d'effet sur le solde indicatif via signedMontant).
const dejaInclusSouscr = r._investType === 'pe' && r.mode_reglement === 'source';
const zeroImpact = horsPM || dejaInclusSouscr;
return ( return (
<tr key={r.id} <tr key={r.id}
className={`dr-row${mvSelectedRow?.id === r.id ? ' dr-row-selected' : ''}`} className={`dr-row${mvSelectedRow?.id === r.id ? ' dr-row-selected' : ''}`}
onClick={() => setMvSelectedRow(mvSelectedRow?.id === r.id ? null : r)} onClick={() => setMvSelectedRow(mvSelectedRow?.id === r.id ? null : r)}
style={{ cursor: 'pointer', opacity: horsPM ? 0.75 : 1 }}> style={{ cursor: 'pointer', opacity: zeroImpact ? 0.75 : 1 }}>
<td>{fmtDate(r.date_operation)}</td> <td>{fmtDate(r.date_operation)}</td>
<td>{r.plateforme_nom}</td> <td>{r.plateforme_nom}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>} {multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>}
@@ -2981,9 +3041,19 @@ export default function Plateformes() {
verticalAlign: 'middle', verticalAlign: 'middle',
}} title="Déjà déduit du montant net du remboursement — aucun effet supplémentaire sur le solde du porte-monnaie">DÉDUIT REMB.</span> }} title="Déjà déduit du montant net du remboursement — aucun effet supplémentaire sur le solde du porte-monnaie">DÉDUIT REMB.</span>
)} )}
{dejaInclusSouscr && (
<span style={{
marginLeft: 6,
fontSize: 10, fontWeight: 600, letterSpacing: '0.03em',
padding: '1px 5px', borderRadius: 3,
background: 'var(--surface-2)',
color: 'var(--text-muted)',
verticalAlign: 'middle',
}} title="Déjà inclus dans le montant de la souscription — aucun effet supplémentaire sur le solde du porte-monnaie">DÉDUIT SOUSCR.</span>
)}
</td> </td>
<td className="text-muted">{r._projet || '—'}</td> <td className="text-muted">{r._investType === 'pe' && <span style={{ display: 'inline-block', fontSize: 10, fontWeight: 700, letterSpacing: '0.03em', padding: '1px 5px', borderRadius: 4, marginRight: 6, background: 'color-mix(in srgb, var(--primary) 15%, transparent)', color: 'var(--primary)' }} title="Mouvement Private Equity">PE</span>}{r._projet || '—'}</td>
<td className={horsPM ? 'num text-muted' : 'num danger'}> <td className={zeroImpact ? 'num text-muted' : 'num danger'}>
− {fmtEUR(r.montant)} − {fmtEUR(r.montant)}
</td> </td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td> <td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
@@ -2992,6 +3062,12 @@ export default function Plateformes() {
); );
} }
const isAuto = r.source === 'auto_remboursement'; const isAuto = r.source === 'auto_remboursement';
// Dépôt/retrait PE (19/09/26) : même rendu que les dépôts crowdlending,
// mais SANS le menu d'édition/suppression — cette ligne vit dans la table
// depots_retraits sous le workspace PE, et l'éditer/la supprimer depuis ce
// menu (pensé pour le workspace crowdlending) réattribuerait sa gestion au
// mauvais endroit. Modifiable uniquement depuis le workspace PE d'origine.
const isForeign = r._investType === 'pe';
return ( return (
<tr key={r.id} <tr key={r.id}
className={`dr-row${mvSelectedRow?.id === r.id ? ' dr-row-selected' : ''}`} className={`dr-row${mvSelectedRow?.id === r.id ? ' dr-row-selected' : ''}`}
@@ -3013,14 +3089,14 @@ export default function Plateformes() {
}}>AUTO</span> }}>AUTO</span>
)} )}
</td> </td>
<td className="text-muted">{r._projet || '—'}</td> <td className="text-muted">{isForeign && <span style={{ display: 'inline-block', fontSize: 10, fontWeight: 700, letterSpacing: '0.03em', padding: '1px 5px', borderRadius: 4, marginRight: 6, background: 'color-mix(in srgb, var(--primary) 15%, transparent)', color: 'var(--primary)' }} title="Mouvement Private Equity">PE</span>}{r._projet || '—'}</td>
<td className={`num ${r.type === 'depot' ? 'success' : 'danger'}`} <td className={`num ${r.type === 'depot' ? 'success' : 'danger'}`}
style={ isAuto ? { fontStyle: 'italic' } : undefined }> style={ isAuto ? { fontStyle: 'italic' } : undefined }>
{r.type === 'depot' ? '+' : '−'} {fmtEUR(r.montant)} {r.type === 'depot' ? '+' : '−'} {fmtEUR(r.montant)}
</td> </td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td> <td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} onClick={e => e.stopPropagation()}> <td style={{ width: 36, textAlign: 'center' }} onClick={e => e.stopPropagation()}>
{!isAuto && ( {!isAuto && !isForeign && (
<button <button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 6px', borderRadius: 4, color: 'var(--text-muted)', lineHeight: 1, fontSize: 16 }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 6px', borderRadius: 4, color: 'var(--text-muted)', lineHeight: 1, fontSize: 16 }}
onClick={e => mvOpenRowMenu(e, r)}>⋮</button> onClick={e => mvOpenRowMenu(e, r)}>⋮</button>