MAJ
This commit is contained in:
1 parent
6bba34396c
commit
1d5737703b
9 files changed
+344
-42
No files matched your search
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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] : []));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)."
|
||||||
|
: (isForeign && row.mode_reglement === 'source')
|
||||||
|
? "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."
|
||||||
: row.mode_reglement === 'source'
|
: 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é — 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."}
|
: "Versement complémentaire réglé depuis le porte-monnaie de la plateforme — sort du porte-monnaie indépendamment du capital investi."}
|
||||||
{' '}{isFraisRembLinked ? "Modifiable depuis le remboursement." : "Modifiable depuis la fiche de l'investissement."}
|
{' '}{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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user