Implementation de la notion d'espace d'investissement

This commit is contained in:
ocroguennec committed 2026-09-17 18:25:42 +02:00
1 parent 6b6cad7cd6
commit 4b7df66862
54 files changed
+6782 -288

No files matched your search

+84 -21
View File
@@ -10,11 +10,17 @@ import ResetPassword from './pages/ResetPassword.jsx';
import VerifyEmail from './pages/VerifyEmail.jsx';
import Layout from './components/Layout.jsx';
import Dashboard from './pages/Dashboard.jsx';
import DashboardPe from './pages/DashboardPe.jsx';
import DepotsRetraits from './pages/DepotsRetraits.jsx';
import DepotsRetraitsSimple from './pages/DepotsRetraitsSimple.jsx';
import Investissements from './pages/Investissements.jsx';
import InvestissementsPe from './pages/InvestissementsPe.jsx';
import InvestissementDetail from './pages/InvestissementDetail.jsx';
import InvestissementPeDetail from './pages/InvestissementPeDetail.jsx';
import Remboursements from './pages/Remboursements.jsx';
import RemboursementsSimple from './pages/RemboursementsSimple.jsx';
import Frais from './pages/Frais.jsx';
import FraisPe from './pages/FraisPe.jsx';
import SimulRemboursements from './pages/SimulRemboursements.jsx';
import TaxReport from './pages/TaxReport.jsx';
import Settings from './pages/Settings.jsx';
@@ -54,6 +60,16 @@ function FraisOnly({ children }) {
return children;
}
// Équivalent PE (chantier "frais PE", 17/09/26) — visible seulement après déclaration d'au
// moins un frais sur un deal Private Equity (hasFraisPe, cf. AuthContext.jsx / GET /auth/me).
// Même mécanisme non réactif que FraisOnly.
function FraisPeOnly({ children }) {
const { hasFraisPe, loading } = useAuth();
if (loading) return <div style={{ padding: 32 }}>Chargement…</div>;
if (!hasFraisPe) return <Navigate to="/" replace />;
return children;
}
// ── Gating de navigation par workspace (Phase 4, étape 0 — 08/09/26) ────
// cf. project_workspaces_transformation.md (mémoire projet). N'agit
// aujourd'hui sur rien de visible tant qu'un seul workspace (crowdlending)
@@ -67,25 +83,72 @@ function RequireWorkspace({ slug, children }) {
return children;
}
// Page d'accueil sensible au workspace actif : le Dashboard crowdlending
// tant que ce workspace est actif (comportement actuel, inchangé), sinon un
// simple message d'attente — appelé à être remplacé par le vrai contenu du
// workspace concerné au fur et à mesure de sa construction (Phase 4+).
// Page d'accueil sensible au workspace actif : le Dashboard crowdlending tant
// que ce workspace est actif (comportement actuel, inchangé), sinon le
// tableau de bord dédié PE (DashboardPe.jsx, chantier "dashboard PE",
// 17/09/26) — première version structurelle (KPI + graphiques de
// portefeuille), à enrichir ensuite.
function WorkspaceHome() {
const { active } = useWorkspace() || {};
if (active && active.slug !== 'crowdlending') {
return (
<div style={{ padding: '48px 24px', textAlign: 'center', color: 'var(--text-muted)' }}>
<h2 style={{ margin: '0 0 8px', color: 'var(--text)' }}>{active.nom}</h2>
<p style={{ margin: 0 }}>
{active.description || "Cet espace de travail n'est pas encore disponible."}
</p>
</div>
);
}
if (active && active.slug !== 'crowdlending') return <DashboardPe />;
return <Dashboard />;
}
// Route /depots-retraits : partagée entre tous les workspaces (17/09/26,
// demande explicite d'Olivier de réutiliser le code de la page crowdlending
// plutôt que d'en construire une séparée à chaque nouveau workspace). Bascule
// sur la version simplifiée (pages/DepotsRetraitsSimple.jsx) pour tout
// workspace autre que crowdlending — jamais lié en dur à un slug précis
// (ex. "private_equity") pour rester valable si un 3e workspace apparaît un
// jour. Fail-open vers la page crowdlending tant que `active` n'est pas
// encore chargé, comme RequireWorkspace ci-dessus.
function DepotsRetraitsRouter() {
const { active } = useWorkspace() || {};
if (active && active.slug !== 'crowdlending') return <DepotsRetraitsSimple />;
return <DepotsRetraits />;
}
// Même principe que DepotsRetraitsRouter ci-dessus (17/09/26) : la page
// Remboursements simplifiée (RemboursementsSimple.jsx, limitée aux types
// bonus/intérêts plateforme — pas de type "normal" lié à un investissement,
// investissements n'ayant pas encore de workspace_id) sert tout workspace
// autre que crowdlending, générique par slug pour rester valable si un 3e
// workspace apparaît un jour.
function RemboursementsRouter() {
const { active } = useWorkspace() || {};
if (active && active.slug !== 'crowdlending') return <RemboursementsSimple />;
return <Remboursements />;
}
// Même principe (17/09/26) : la page Investissements bascule sur la table
// dédiée investissements_pe (InvestissementsPe.jsx) pour tout workspace
// autre que crowdlending — modèle de données volontairement différent
// (montant unique investi + valorisations périodiques, pas d'échéancier),
// cf. project_workspaces_transformation.md.
function InvestissementsRouter() {
const { active } = useWorkspace() || {};
if (active && active.slug !== 'crowdlending') return <InvestissementsPe />;
return <Investissements />;
}
// Fiche détaillée /investissements/:id (chantier "fiche détail PE", 17/09/26) :
// structure encore minimale côté PE (résumé, valorisations, remboursements,
// frais, documents), à enrichir ensuite (cf. InvestissementPeDetail.jsx).
function InvestissementDetailRouter() {
const { active } = useWorkspace() || {};
if (active && active.slug !== 'crowdlending') return <InvestissementPeDetail />;
return <InvestissementDetail />;
}
// Même principe (chantier "frais PE", 17/09/26) : la page Frais dédiée existe désormais pour
// les deux workspaces (FraisPe.jsx pour tout workspace non-crowdlending), chacune gardée par
// son propre indicateur "au moins un frais déclaré" (FraisOnly / FraisPeOnly ci-dessus).
function FraisRouter() {
const { active } = useWorkspace() || {};
if (active && active.slug !== 'crowdlending') return <FraisPeOnly><FraisPe /></FraisPeOnly>;
return <FraisOnly><Frais /></FraisOnly>;
}
export default function App() {
const [allowRegistration, setAllowRegistration] = useState(true);
@@ -131,11 +194,11 @@ export default function App() {
<Route element={<Protected><Layout /></Protected>}>
<Route index element={<WorkspaceHome />} />
<Route path="plateformes" element={<RequireWorkspace slug="crowdlending"><Plateformes /></RequireWorkspace>} />
<Route path="depots-retraits" element={<RequireWorkspace slug="crowdlending"><DepotsRetraits /></RequireWorkspace>} />
<Route path="investissements" element={<RequireWorkspace slug="crowdlending"><Investissements /></RequireWorkspace>} />
<Route path="investissements/:id" element={<RequireWorkspace slug="crowdlending"><InvestissementDetail /></RequireWorkspace>} />
<Route path="remboursements" element={<RequireWorkspace slug="crowdlending"><Remboursements /></RequireWorkspace>} />
<Route path="frais" element={<RequireWorkspace slug="crowdlending"><FraisOnly><Frais /></FraisOnly></RequireWorkspace>} />
<Route path="depots-retraits" element={<DepotsRetraitsRouter />} />
<Route path="investissements" element={<InvestissementsRouter />} />
<Route path="investissements/:id" element={<InvestissementDetailRouter />} />
<Route path="remboursements" element={<RemboursementsRouter />} />
<Route path="frais" element={<FraisRouter />} />
<Route path="simul" element={<RequireWorkspace slug="crowdlending"><SimulRemboursements /></RequireWorkspace>} />
<Route path="taxreport" element={<RequireWorkspace slug="crowdlending"><TaxReport /></RequireWorkspace>} />
<Route path="assistant-ia" element={<RequireWorkspace slug="crowdlending"><AssistantIA /></RequireWorkspace>} />
@@ -145,8 +208,8 @@ export default function App() {
<Route path="preferences" element={<Navigate to="/settings?section=apparence" replace />} />
<Route path="compte" element={<MonCompte />} />
<Route path="admin" element={<AdminOnly><Admin /></AdminOnly>} />
<Route path="admin/plateformes" element={<RequireWorkspace slug="crowdlending"><AdminOnly><AdminPlateformes /></AdminOnly></RequireWorkspace>} />
<Route path="admin/fiscalite" element={<RequireWorkspace slug="crowdlending"><AdminOnly><AdminFiscalite /></AdminOnly></RequireWorkspace>} />
<Route path="admin/plateformes" element={<AdminOnly><AdminPlateformes /></AdminOnly>} />
<Route path="admin/fiscalite" element={<AdminOnly><AdminFiscalite /></AdminOnly>} />
<Route path="admin/ia" element={<AdminOnly><AdminIA /></AdminOnly>} />
<Route path="aide" element={<Aide />} />
<Route path="notifications" element={<Notifications />} />
+5
View File
@@ -6,10 +6,15 @@ function authHeaders() {
const token = localStorage.getItem('cl_token');
const investisseurId = localStorage.getItem('cl_investisseur_id');
const deviceToken = localStorage.getItem('cl_device_token');
// Même clé que WorkspaceContext.jsx (KEY_ACTIVE) — miroir de X-Investisseur-Id,
// sert à flêcher côté serveur les nouvelles lignes vers le workspace actif
// (Étape 2a du retrofit workspace_id, cf. workspaceScope.js).
const workspaceId = localStorage.getItem('cl_active_workspace_id');
const h = {};
if (token) h['Authorization'] = `Bearer ${token}`;
if (investisseurId) h['X-Investisseur-Id'] = investisseurId;
if (deviceToken) h['X-Device-Token'] = deviceToken;
if (workspaceId) h['X-Workspace-Id'] = workspaceId;
return h;
}
+14 -3
View File
@@ -1,4 +1,5 @@
import Modal from './Modal.jsx';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
/**
* Modale de confirmation générique.
@@ -7,8 +8,15 @@ import Modal from './Modal.jsx';
* title — titre de la modale (défaut : "Confirmer la suppression")
* message — texte explicatif
* confirmLabel — libellé du bouton de confirmation (défaut : "Supprimer")
* onConfirm — callback appelé au clic "Confirmer"
* onConfirm — callback appelé au clic "Confirmer" (peut être async)
* onCancel — callback appelé au clic "Annuler" ou ✕
*
* Protégée nativement contre le double-clic (cf. useAsyncAction) : "Confirmer" se
* désactive dès le premier clic (retour visuel via le style global button:disabled)
* et bloque aussi "Annuler"/✕ tant que la requête est en cours — évite de déclencher
* deux fois la même action (suppression, etc.) sur une connexion lente. Le libellé du
* bouton n'est pas réécrit ici : certains appelants font déjà varier `confirmLabel`
* eux-mêmes pendant l'opération (ex. "Suppression…") et gardent la main dessus.
*/
export default function ConfirmModal({
open,
@@ -18,16 +26,19 @@ export default function ConfirmModal({
onConfirm,
onCancel,
}) {
const { run, pending } = useAsyncAction();
return (
<Modal
open={open}
title={title}
onClose={onCancel}
width={440}
busy={pending}
footer={
<>
<button className="ghost" type="button" onClick={onCancel}>Annuler</button>
<button className="danger" type="button" onClick={onConfirm}>{confirmLabel}</button>
<button className="ghost" type="button" disabled={pending} onClick={onCancel}>Annuler</button>
<button className="danger" type="button" disabled={pending} onClick={() => run(onConfirm)}>{confirmLabel}</button>
</>
}
>
+9 -5
View File
@@ -44,6 +44,7 @@ export default function FraisFormModal({
locked = false,
investissementOptions = [],
confirmingDelete, setConfirmingDelete,
submitting = false,
}) {
const modalTitle = editingId ? 'Modifier le frais' : 'Ajouter un frais';
@@ -53,12 +54,13 @@ export default function FraisFormModal({
title={modalTitle}
onClose={onClose}
width={locked ? 480 : 560}
busy={submitting}
footer={
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 8 }}>
<div style={{ flex: 1 }}>
{editingId && onDelete && (
!confirmingDelete ? (
<button className="danger" onClick={() => setConfirmingDelete(true)}>
<button className="danger" disabled={submitting} onClick={() => setConfirmingDelete(true)}>
Supprimer
</button>
) : (
@@ -66,14 +68,16 @@ export default function FraisFormModal({
<span style={{ fontSize: 13, color: 'var(--danger, #ef4444)', whiteSpace: 'nowrap' }}>
Supprimer définitivement ?
</span>
<button onClick={() => setConfirmingDelete(false)}>Non</button>
<button className="danger" onClick={onDelete}>Oui, supprimer</button>
<button disabled={submitting} onClick={() => setConfirmingDelete(false)}>Non</button>
<button className="danger" disabled={submitting} onClick={onDelete}>{submitting ? 'Suppression…' : 'Oui, supprimer'}</button>
</span>
)
)}
</div>
<button className="ghost" onClick={onClose}>Annuler</button>
<button className="primary" onClick={onSubmit}>{editingId ? 'Enregistrer' : 'Ajouter'}</button>
<button className="ghost" disabled={submitting} onClick={onClose}>Annuler</button>
<button className="primary" disabled={submitting} onClick={onSubmit}>
{submitting ? (editingId ? 'Enregistrement…' : 'Ajout…') : (editingId ? 'Enregistrer' : 'Ajouter')}
</button>
</div>
}
>
@@ -73,6 +73,7 @@ export default function InvestissementFormModal({
dateCibleRevisionWarning = null,
restructurationBlock = null,
width = 720,
submitting = false,
}) {
// Vestige côté données : mémorise la dernière souscription valide saisie.
// N'est lu nulle part dans le code actuel — conservé tel quel (écriture sans effet)
@@ -285,12 +286,13 @@ export default function InvestissementFormModal({
title={editingId ? 'Modifier l\'investissement' : 'Nouvel investissement'}
onClose={onClose}
width={width}
busy={submitting}
footer={
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 8 }}>
<div style={{ flex: 1 }}>
{editingId && onDelete && (
!confirmingDelete ? (
<button className="danger" onClick={() => setConfirmingDelete(true)}>
<button className="danger" disabled={submitting} onClick={() => setConfirmingDelete(true)}>
Supprimer
</button>
) : (
@@ -298,16 +300,20 @@ export default function InvestissementFormModal({
<span style={{ fontSize: 13, color: 'var(--danger, #ef4444)', whiteSpace: 'nowrap' }}>
Supprimer définitivement (documents inclus) ?
</span>
<button onClick={() => setConfirmingDelete(false)}>Non</button>
<button className="danger" onClick={onDelete}>Oui, supprimer</button>
<button disabled={submitting} onClick={() => setConfirmingDelete(false)}>Non</button>
<button className="danger" disabled={submitting} onClick={onDelete}>{submitting ? 'Suppression…' : 'Oui, supprimer'}</button>
</span>
)
)}
</div>
{step === 1 && <button onClick={onClose}>Annuler</button>}
{step > 1 && <button onClick={goPrev}>Précédent</button>}
{step < totalSteps && <button className="primary" onClick={goNext}>Suivant</button>}
{step === totalSteps && <button className="primary" onClick={onSubmit}>{editingId ? 'Enregistrer' : 'Créer'}</button>}
{step === 1 && <button disabled={submitting} onClick={onClose}>Annuler</button>}
{step > 1 && <button disabled={submitting} onClick={goPrev}>Précédent</button>}
{step < totalSteps && <button className="primary" disabled={submitting} onClick={goNext}>Suivant</button>}
{step === totalSteps && (
<button className="primary" disabled={submitting} onClick={onSubmit}>
{submitting ? (editingId ? 'Enregistrement…' : 'Création…') : (editingId ? 'Enregistrer' : 'Créer')}
</button>
)}
</div>
}
>
@@ -0,0 +1,246 @@
import { useEffect, useRef, useState } from 'react';
import Modal from './Modal.jsx';
/**
* Modale d'ajout/modification d'un deal Private Equity, PAR ÉTAPES (chantier "frais PE",
* 17/09/26 — demande Olivier : "adapte la modale d'investissement, un peu sur le style de la
* modal crowdlending (par étape) pour pouvoir aussi rentrer les frais").
*
* Adaptation de InvestissementFormModal.jsx (crowdlending) : Étape 1 "Infos du deal" toujours
* présente, Étape 2 "Frais" conditionnelle — uniquement si la plateforme sélectionnée facture
* des frais (référentiel frais_plateforme ≠ 'aucun', configuré dans Settings > Plateformes,
* déjà générique/non spécifique à un workspace).
*
* Contrairement à crowdlending (une seule ligne "frais de souscription"), l'étape Frais capture
* ICI DEUX lignes indépendantes — Frais d'entrée et Frais de gestion — conformément aux
* captures Fundora montrées par Olivier (3 deals, chacun distinguant les deux montants). Chaque
* ligne journalise sa propre entrée frais_operations : Frais d'entrée → catégorie
* 'souscription' (unique par deal, cf. assertUniqueSouscription côté backend) ; Frais de
* gestion → catégorie 'gestion', origine 'souscription' (ce qui la distingue, côté
* pré-remplissage, d'un frais de gestion ajouté ultérieurement depuis la page Frais PE ou
* depuis la liste des investissements — ceux-là restent en origine 'manuel').
*
* Composant "bête" : tout le state du formulaire appartient au parent (InvestissementsPe.jsx,
* mêmes conventions que Investissements.jsx / InvestissementFormModal.jsx côté crowdlending) —
* cette modale ne possède que l'état d'étape courante (`step`).
*/
export default function InvestissementPeFormModal({
open, editingId, onClose,
form, setForm, plats, investisseurs, multiDetenteur, comptesInvestisseur,
err, submitting, onSubmit, onDelete, investisseurForPlat,
}) {
const [step, setStep] = useState(1);
const formRef = useRef(null);
useEffect(() => { if (open) setStep(1); }, [open]);
const platFrais = plats.find(p => String(p.id) === String(form.plateforme_id))?.frais_plateforme;
const showFraisStep = !!platFrais && platFrais !== 'aucun';
const totalSteps = showFraisStep ? 2 : 1;
const stepTitles = showFraisStep ? ['Infos du deal', 'Frais'] : ['Infos du deal'];
const goNext = () => {
if (formRef.current && !formRef.current.reportValidity()) return;
setStep(s => Math.min(totalSteps, s + 1));
};
const goPrev = () => setStep(s => Math.max(1, s - 1));
const setField = (key, value) => setForm(f => ({ ...f, [key]: value }));
const handleFraisReglementChange = (prefix, mode) => {
setForm(f => ({
...f,
[`${prefix}_mode_reglement`]: mode,
[`${prefix}_compte_id`]: mode === 'compte_courant' ? f[`${prefix}_compte_id`] : '',
}));
};
return (
<Modal
open={open}
title={editingId ? "Modifier l'investissement" : 'Nouvel investissement'}
onClose={onClose}
busy={submitting}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<div>
{editingId && step === 1 && (
<button className="danger" disabled={submitting} onClick={onDelete}>Supprimer</button>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
{step === 1 && <button disabled={submitting} onClick={onClose}>Annuler</button>}
{step > 1 && <button disabled={submitting} onClick={goPrev}>Précédent</button>}
{step < totalSteps && <button className="primary" disabled={submitting} onClick={goNext}>Suivant</button>}
{step === totalSteps && (
<button className="primary" disabled={submitting} onClick={onSubmit}>
{submitting ? (editingId ? 'Enregistrement…' : 'Création…') : (editingId ? 'Enregistrer' : 'Créer')}
</button>
)}
</div>
</div>
}
>
{totalSteps > 1 && (
<>
<div style={{ display: 'flex', gap: 4, marginBottom: 10 }}>
{Array.from({ length: totalSteps }).map((_, i) => (
<div key={i} style={{
flex: 1, height: 4, borderRadius: 2,
background: i < step ? 'var(--primary)' : 'var(--border)',
}} />
))}
</div>
<div className="text-muted" style={{ fontSize: 13, marginBottom: 14 }}>
Étape {step}/{totalSteps} — {stepTitles[step - 1]}
</div>
</>
)}
<form ref={formRef}>
{err && <div className="error">{err}</div>}
{step === 1 && (
<div className="form-grid">
<div style={{ gridColumn: '1 / -1' }}>
<label>Nom du deal *</label>
<input required value={form.nom_deal} onChange={e => setField('nom_deal', e.target.value)} placeholder="Ex. Cornerstone Growth" />
</div>
<div>
<label>Plateforme *</label>
<select required value={form.plateforme_id} onChange={e => {
const platId = e.target.value;
setForm(f => ({ ...f, plateforme_id: platId, investisseur_id: investisseurForPlat(platId) }));
}}>
<option value="">—</option>
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
</select>
</div>
<div>
<label>Stratégie</label>
<input value={form.strategie} onChange={e => setField('strategie', e.target.value)} placeholder="Ex. Capital risque" />
</div>
<div>
<label>Date de souscription *</label>
<input type="date" required value={form.date_souscription} onChange={e => setField('date_souscription', e.target.value)} />
</div>
<div>
<label>Durée (années)</label>
<input type="number" step="0.5" min="0" value={form.duree_annees} onChange={e => setField('duree_annees', e.target.value)} />
</div>
<div>
<label>Montant investi (€) *</label>
<input type="number" step="0.01" min="0" required value={form.montant_investi} onChange={e => setField('montant_investi', e.target.value)} />
</div>
<div>
<label>Objectif</label>
<input value={form.objectif} onChange={e => setField('objectif', e.target.value)} placeholder="Ex. x4,5 ou 9% - 12%/an" />
</div>
<div>
<label>Statut</label>
<select value={form.statut} onChange={e => setField('statut', e.target.value)}>
<option value="en_attente">En attente</option>
<option value="valide">Validé</option>
<option value="cloture">Clôturé</option>
<option value="perte_definitive">Perte définitive</option>
</select>
</div>
<div>
<label>Référence</label>
<input value={form.reference} onChange={e => setField('reference', e.target.value)} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Détenteur *</label>
<select required value={form.investisseur_id} onChange={e => setField('investisseur_id', e.target.value)}>
<option value="">—</option>
{investisseurs.filter(i => i.type !== 'entreprise').length > 0 && (
<optgroup label="Famille">
{investisseurs.filter(i => i.type !== 'entreprise').map(i => (
<option key={i.id} value={i.id}>{i.nom}{i.is_principal ? ' (principal)' : ''}</option>
))}
</optgroup>
)}
{investisseurs.filter(i => i.type === 'entreprise').length > 0 && (
<optgroup label="Entreprises">
{investisseurs.filter(i => i.type === 'entreprise').map(i => (
<option key={i.id} value={i.id}>{i.nom}</option>
))}
</optgroup>
)}
</select>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Notes</label>
<input value={form.notes} onChange={e => setField('notes', e.target.value)} />
</div>
</div>
)}
{step === 2 && showFraisStep && (
<>
<p className="text-muted" style={{ fontSize: 13, marginTop: 0, marginBottom: 14 }}>
Cette plateforme facture des frais (entrée, gestion…). Indiquez les montants TTC
constatés pour cette souscription, ou laissez un montant à 0/vide si le frais
correspondant ne s'applique pas cette fois-ci.
</p>
<FraisBlock prefix="frais_entree" label="Frais d'entrée" form={form} setField={setField}
handleFraisReglementChange={handleFraisReglementChange} comptesInvestisseur={comptesInvestisseur} />
<FraisBlock prefix="frais_gestion" label="Frais de gestion" form={form} setField={setField}
handleFraisReglementChange={handleFraisReglementChange} comptesInvestisseur={comptesInvestisseur} />
</>
)}
</form>
</Modal>
);
}
function FraisBlock({ prefix, label, form, setField, handleFraisReglementChange, comptesInvestisseur }) {
const modeSaisie = form[`${prefix}_mode_saisie`];
const valeur = form[`${prefix}_valeur`];
const modeReglement = form[`${prefix}_mode_reglement`];
const compteId = form[`${prefix}_compte_id`];
return (
<div style={{ marginBottom: 18, padding: 12, border: '1px solid var(--border)', borderRadius: 8 }}>
<div style={{ fontWeight: 600, marginBottom: 10 }}>{label}</div>
<div className="form-grid">
<div>
<label>Mode de saisie</label>
<select value={modeSaisie} onChange={e => setField(`${prefix}_mode_saisie`, e.target.value)}>
<option value="montant">Montant (€)</option>
<option value="pourcentage">% du montant investi</option>
</select>
</div>
<div>
<label>{modeSaisie === 'pourcentage' ? `${label} (% du montant investi)` : `${label} TTC (€)`}</label>
<input type="number" step="0.01" min="0" value={valeur} onChange={e => setField(`${prefix}_valeur`, e.target.value)} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Prélèvement</label>
<select value={modeReglement} onChange={e => handleFraisReglementChange(prefix, e.target.value)}>
<option value="source">À la source (sans mouvement complémentaire)</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>
</select>
</div>
{modeReglement === 'compte_courant' && (
<div style={{ gridColumn: '1 / -1' }}>
<label>Compte de règlement *</label>
{comptesInvestisseur.length > 0 ? (
<select required value={compteId} onChange={e => setField(`${prefix}_compte_id`, e.target.value)}>
<option value="">— Choisir un compte —</option>
{comptesInvestisseur.map(c => (
<option key={c.id} value={c.id}>{c.nom}{c.banque ? ` — ${c.banque}` : ''}</option>
))}
</select>
) : (
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: '4px 0' }}>
Aucun compte défini pour ce détenteur. <a href="/settings?section=comptes" target="_blank" rel="noreferrer">Créer un compte →</a>
</p>
)}
</div>
)}
</div>
</div>
);
}
+45 -20
View File
@@ -252,12 +252,23 @@ const IconBurger = () => (
);
export default function Layout() {
const { sidebarCollapsed, setSidebarCollapsed, toggleSidebar, displayMode, setDisplayMode } = useUi();
const { hasFrais } = useAuth();
const { active: activeWorkspace } = useWorkspace() || {};
const { sidebarCollapsed, setSidebarCollapsed, toggleSidebar, displayMode, setDisplayMode, setWorkspaceColorOverride } = useUi();
const { hasFrais, hasFraisPe } = useAuth();
const { active: activeWorkspace, hasMultipleWorkspaces } = useWorkspace() || {};
// Couleur d'accent par espace de travail (17/09/26, cf. AppearanceSection.jsx) :
// à chaque changement d'espace actif, applique sa couleur personnalisée
// (ou repli sur la couleur de l'application si non définie) — seulement
// pertinent quand plusieurs espaces sont actifs (sinon rien à distinguer).
useEffect(() => {
setWorkspaceColorOverride(hasMultipleWorkspaces ? (activeWorkspace?.color_theme || null) : null);
}, [activeWorkspace, hasMultipleWorkspaces, setWorkspaceColorOverride]);
// Gating de nav par workspace (Phase 4, étape 0) : fail-open tant que
// activeWorkspace n'est pas chargé, cf. App.jsx (RequireWorkspace).
const isCrowdlendingActive = !activeWorkspace || activeWorkspace.slug === 'crowdlending';
// Lien "Frais" (chantier "frais PE", 17/09/26) : visible dans les deux workspaces, chacun
// avec son propre indicateur "au moins un frais déclaré" (hasFrais / hasFraisPe).
const showFrais = isCrowdlendingActive ? hasFrais : hasFraisPe;
const navigate = useNavigate();
const [navIcons, setNavIcons] = useState({});
const [aiEnabled, setAiEnabled] = useState(false);
@@ -332,25 +343,39 @@ export default function Layout() {
<NavLink to="/" end title="Tableau de bord">
<NavIcon libFilename={navIcons.dashboard} Fallback={IconDashboard} /><span className="nav-label">Tableau de bord</span>
</NavLink>
{/* Plateformes reste propre au crowdlending pour l'instant (pas
d'équivalent PE — les plateformes PE se gèrent depuis Mes
plateformes, settings). Investissements, Dépôts/Retraits et
Remboursements (17/09/26) sont en revanche visibles quel que soit
le workspace actif — la route bascule automatiquement sur la
version adaptée (InvestissementsRouter / DepotsRetraitsRouter /
RemboursementsRouter, App.jsx). Chaque lien est conditionné
individuellement plutôt que regroupé dans un seul bloc
isCrowdlendingActive, pour conserver l'ordre historique du menu
crowdlending (Plateformes, Investissements, Dépôts/Retraits,
Remboursements, Frais...) au lieu de faire remonter ces liens en
tête — cf. capture d'Olivier, 17/09/26. */}
{isCrowdlendingActive && (
<NavLink to="/plateformes" title="Plateformes">
<NavIcon libFilename={navIcons.plateforme} Fallback={IconInvestments} /><span className="nav-label">Plateformes</span>
</NavLink>
)}
<NavLink to="/investissements" title="Investissements">
<NavIcon libFilename={navIcons.investissement} Fallback={IconInvestments} /><span className="nav-label">Investissements</span>
</NavLink>
<NavLink to="/depots-retraits" title="Dépôts / Retraits">
<NavIcon libFilename={navIcons['depots-retraits']} Fallback={IconDeposits} /><span className="nav-label">Dépôts / Retraits</span>
</NavLink>
<NavLink to="/remboursements" title="Remboursements">
<NavIcon libFilename={navIcons.remboursement} Fallback={IconRepayments} /><span className="nav-label">Remboursements</span>
</NavLink>
{showFrais && (
<NavLink to="/frais" title="Frais">
<NavIcon libFilename={navIcons.frais} Fallback={IconFrais} /><span className="nav-label">Frais</span>
</NavLink>
)}
{isCrowdlendingActive && (
<>
<NavLink to="/plateformes" title="Plateformes">
<NavIcon libFilename={navIcons.plateforme} Fallback={IconInvestments} /><span className="nav-label">Plateformes</span>
</NavLink>
<NavLink to="/investissements" title="Investissements">
<NavIcon libFilename={navIcons.investissement} Fallback={IconInvestments} /><span className="nav-label">Investissements</span>
</NavLink>
<NavLink to="/depots-retraits" title="Dépôts / Retraits">
<NavIcon libFilename={navIcons['depots-retraits']} Fallback={IconDeposits} /><span className="nav-label">Dépôts / Retraits</span>
</NavLink>
<NavLink to="/remboursements" title="Remboursements">
<NavIcon libFilename={navIcons.remboursement} Fallback={IconRepayments} /><span className="nav-label">Remboursements</span>
</NavLink>
{hasFrais && (
<NavLink to="/frais" title="Frais">
<NavIcon libFilename={navIcons.frais} Fallback={IconFrais} /><span className="nav-label">Frais</span>
</NavLink>
)}
<NavLink to="/taxreport" title="Fiscalité">
<NavIcon libFilename={navIcons.tax} Fallback={IconFlatTax} /><span className="nav-label">Fiscalité</span>
</NavLink>
+6 -2
View File
@@ -1,5 +1,9 @@
export default function Modal({ open, title, onClose, children, footer, width = 600 }) {
export default function Modal({ open, title, onClose, children, footer, width = 600, busy = false }) {
if (!open) return null;
// busy : une soumission est en cours (cf. useAsyncAction) — on désactive la fermeture
// par le ✕ pour éviter de quitter la modale au milieu d'une requête (l'utilisateur perdrait
// le suivi de son état et pourrait être tenté de rouvrir/ressaisir).
const handleClose = busy ? undefined : onClose;
return (
<div className="modal-backdrop">
<div
@@ -9,7 +13,7 @@ export default function Modal({ open, title, onClose, children, footer, width =
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h3 style={{ margin: 0 }}>{title}</h3>
<button className="ghost" onClick={onClose}>✕</button>
<button className="ghost" onClick={handleClose} disabled={busy} aria-disabled={busy}>✕</button>
</div>
{children}
{footer && <div style={{ marginTop: 16, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>{footer}</div>}
+8 -6
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import * as XLSX from 'xlsx';
import { fmtEUR, fmtDate, today } from '../utils/format.js';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
/* ── Éléments partagés entre l'onglet "Mouvements" de DepotsRetraits.jsx et l'onglet
"Mouvements Porte-monnaie" de Plateformes.jsx. Toute évolution ici s'applique aux deux
@@ -695,9 +696,10 @@ export function CorrectionModal({ open, onClose, onConfirm,
const diff = Math.round((Number(declared) - computedBalance) * 100) / 100;
const hasDiff = Math.abs(diff) >= 0.005;
const handleConfirm = () => {
onConfirm(hasDiff ? diff : 0);
};
// Anti-double-soumission (cf. useAsyncAction) : évite de créer deux fois la même correction
// sur un double-clic / une connexion lente, avant que la modale ne se ferme.
const { run, pending } = useAsyncAction();
const handleConfirm = () => run(() => onConfirm(hasDiff ? diff : 0));
if (!open) return null;
return (
@@ -773,9 +775,9 @@ export function CorrectionModal({ open, onClose, onConfirm,
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}>
<button onClick={onClose}>Passer</button>
<button className="primary" onClick={handleConfirm}>
{hasDiff ? 'Créer la correction' : 'OK'}
<button disabled={pending} onClick={onClose}>Passer</button>
<button className="primary" disabled={pending} onClick={handleConfirm}>
{pending ? '…' : (hasDiff ? 'Créer la correction' : 'OK')}
</button>
</div>
</div>
+9 -5
View File
@@ -71,6 +71,7 @@ export default function RembFormModal({
investissementOptions = [],
multiDetenteur = false,
confirmingDelete, setConfirmingDelete,
submitting = false,
}) {
const isBonus = !locked && BONUS_VALUES.includes(form.investissement_id);
const isInteretsPlateforme = !locked && form.investissement_id === INTERETS_PLATEFORME_VALUE;
@@ -128,12 +129,13 @@ export default function RembFormModal({
title={modalTitle}
onClose={onClose}
width={900}
busy={submitting}
footer={
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 8 }}>
<div style={{ flex: 1 }}>
{editingId && onDelete && (
!confirmingDelete ? (
<button className="danger" onClick={() => setConfirmingDelete(true)}>
<button className="danger" disabled={submitting} onClick={() => setConfirmingDelete(true)}>
Supprimer
</button>
) : (
@@ -141,14 +143,16 @@ export default function RembFormModal({
<span style={{ fontSize: 13, color: 'var(--danger, #ef4444)', whiteSpace: 'nowrap' }}>
Supprimer définitivement ?
</span>
<button onClick={() => setConfirmingDelete(false)}>Non</button>
<button className="danger" onClick={onDelete}>Oui, supprimer</button>
<button disabled={submitting} onClick={() => setConfirmingDelete(false)}>Non</button>
<button className="danger" disabled={submitting} onClick={onDelete}>{submitting ? 'Suppression…' : 'Oui, supprimer'}</button>
</span>
)
)}
</div>
<button onClick={onClose}>Annuler</button>
<button className="primary" onClick={onSubmit}>{editingId ? 'Enregistrer' : 'Créer'}</button>
<button disabled={submitting} onClick={onClose}>Annuler</button>
<button className="primary" disabled={submitting} onClick={onSubmit}>
{submitting ? (editingId ? 'Enregistrement…' : 'Création…') : (editingId ? 'Enregistrer' : 'Créer')}
</button>
</div>
}
>
+56 -22
View File
@@ -132,10 +132,11 @@ export default function UserMenu() {
const [popupStyle, setPopupStyle] = useState({});
const [subStyle, setSubStyle] = useState({});
const triggerRef = useRef(null);
const popupRef = useRef(null);
const subRef = useRef(null);
const closeTimer = useRef(null);
const triggerRef = useRef(null);
const popupRef = useRef(null);
const subRef = useRef(null);
const closeTimer = useRef(null);
const activeRowRef = useRef(null); // bouton (ligne) qui a ouvert le sous-panneau actif
const famille = investisseurs.filter(i => i.type !== 'entreprise');
const entreprises = investisseurs.filter(i => i.type === 'entreprise');
@@ -143,7 +144,7 @@ export default function UserMenu() {
const POPUP_W = 250;
const SUB_W = 230;
/* ── Calcul position (fixed = échappe overflow:hidden) ────── */
/* ── Calcul position du popup principal (fixed = échappe overflow:hidden) ── */
const computePosition = useCallback(() => {
if (!triggerRef.current) return;
const r = triggerRef.current.getBoundingClientRect();
@@ -165,16 +166,30 @@ export default function UserMenu() {
position: 'fixed', left: mainLeft, bottom: mainBottom,
width: mainWidth, top: 'auto', right: 'auto',
});
setSubStyle({
position: 'fixed',
left: mainLeft + (sidebarCollapsed ? POPUP_W : mainWidth) + 6,
bottom: mainBottom,
width: SUB_W, top: 'auto', right: 'auto',
});
}, [sidebarCollapsed]);
// Position du sous-panneau (Espace de travail / Vue du profil) : ancrée sur
// le bas de la LIGNE cliquée (rowEl), pas sur le popup entier — sinon le
// sous-panneau apparaît décalé en bas de la fenêtre dès que la ligne
// cliquée n'est pas tout en bas du popup principal (ex. "Espace de
// travail", proche du haut). Affiché à droite du popup, à la même hauteur
// que la ligne cliquée.
const computeSubPosition = useCallback((rowEl) => {
if (!rowEl || !popupRef.current) return;
const rowRect = rowEl.getBoundingClientRect();
const popupRect = popupRef.current.getBoundingClientRect();
const vh = window.visualViewport ? window.visualViewport.height : window.innerHeight;
setSubStyle({
position: 'fixed',
left: popupRect.right + 6,
bottom: vh - rowRect.bottom,
width: SUB_W, top: 'auto', right: 'auto',
});
}, []);
const openMenu = useCallback(() => { computePosition(); setOpen(true); }, [computePosition]);
const closeMenu = useCallback(() => { setOpen(false); setActiveSubPanel(null); }, []);
const closeMenu = useCallback(() => { setOpen(false); setActiveSubPanel(null); activeRowRef.current = null; }, []);
const clearClose = () => clearTimeout(closeTimer.current);
const scheduleClose = () => { closeTimer.current = setTimeout(closeMenu, 200); };
@@ -192,20 +207,29 @@ export default function UserMenu() {
return () => document.removeEventListener('mousedown', onDown);
}, [open, closeMenu]);
/* Recalcul si sidebar change ou si visualViewport change (Safari mobile) */
useEffect(() => { if (open) computePosition(); }, [sidebarCollapsed, open, computePosition]);
/* Recalcul si sidebar change ou si visualViewport change (Safari mobile) —
* y compris le sous-panneau, ré-ancré sur sa ligne d'origine (activeRowRef). */
useEffect(() => {
if (!open) return;
computePosition();
if (activeRowRef.current) computeSubPosition(activeRowRef.current);
}, [sidebarCollapsed, open, computePosition, computeSubPosition]);
useEffect(() => {
if (!open) return;
const vp = window.visualViewport;
if (!vp) return;
vp.addEventListener('resize', computePosition);
vp.addEventListener('scroll', computePosition);
return () => {
vp.removeEventListener('resize', computePosition);
vp.removeEventListener('scroll', computePosition);
const recompute = () => {
computePosition();
if (activeRowRef.current) computeSubPosition(activeRowRef.current);
};
}, [open, computePosition]);
vp.addEventListener('resize', recompute);
vp.addEventListener('scroll', recompute);
return () => {
vp.removeEventListener('resize', recompute);
vp.removeEventListener('scroll', recompute);
};
}, [open, computePosition, computeSubPosition]);
/* Handlers ouverture */
const onWrapEnter = () => { if (sidebarCollapsed) { clearClose(); openMenu(); } };
@@ -281,7 +305,12 @@ export default function UserMenu() {
<button
className={`user-menu-item user-menu-vue-row${activeSubPanel === 'workspace' ? ' active' : ''}`}
role="menuitem"
onClick={() => setActiveSubPanel(p => p === 'workspace' ? null : 'workspace')}
onClick={(e) => {
const next = activeSubPanel === 'workspace' ? null : 'workspace';
setActiveSubPanel(next);
activeRowRef.current = next ? e.currentTarget : null;
if (next) computeSubPosition(e.currentTarget);
}}
>
<WorkspaceBadge size={24} />
<span className="user-menu-vue-name">{activeWorkspace?.libelle_menu || activeWorkspace?.nom || 'Espace de travail'}</span>
@@ -297,7 +326,12 @@ export default function UserMenu() {
<button
className={`user-menu-item user-menu-vue-row${activeSubPanel === 'profile' ? ' active' : ''}`}
role="menuitem"
onClick={() => setActiveSubPanel(p => p === 'profile' ? null : 'profile')}
onClick={(e) => {
const next = activeSubPanel === 'profile' ? null : 'profile';
setActiveSubPanel(next);
activeRowRef.current = next ? e.currentTarget : null;
if (next) computeSubPosition(e.currentTarget);
}}
>
{activeView === 'all'
? <TeamBadge size={24} />
@@ -0,0 +1,237 @@
import { useMemo, useState } from 'react';
import { fmtEUR } from '../utils/format.js';
const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
function endOfMonth(Y, M) {
const d = new Date(Y, M, 0);
return `${Y}-${String(M).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
}
/**
* Vision mensuelle — Private Equity (17/09/26). Équivalent PE de
* CapitalMensuelTable.jsx (crowdlending), mais avec une sémantique
* différente (décision Olivier) : chaque cellule est la VALORISATION
* connue en fin de mois (dernière valorisation saisie à cette date, ou
* montant_investi si aucune valorisation n'a encore été saisie), et non
* un capital en cours qui décroît avec des remboursements — un deal PE
* peut donc voir sa valeur monter ou descendre d'un mois à l'autre.
*
* `rows` : réponse de GET /investissements-pe (id, plateforme_id,
* plateforme_nom, montant_investi, date_souscription, statut…).
* `valorisations` : réponse de GET /investissements-pe/valorisations
* (investissement_pe_id, date_valorisation, valorisation_nette), tous
* deals confondus.
*/
export default function ValorisationMensuelleTable({ rows, valorisations, plats, expandButton }) {
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
const [annee, setAnnee] = useState(currentYear);
const availableYears = useMemo(() => {
const set = new Set(rows.map(r => r.date_souscription?.slice(0,4)).filter(Boolean));
set.add(String(currentYear));
return [...set].map(Number).sort((a,b) => a - b);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows]);
/* ── Historique de valorisations trié, par deal ── */
const valorisationsByDeal = useMemo(() => {
const map = {};
for (const v of (valorisations || [])) {
const d = v.date_valorisation?.slice(0, 10);
if (!d || v.valorisation_nette == null) continue;
if (!map[v.investissement_pe_id]) map[v.investissement_pe_id] = [];
map[v.investissement_pe_id].push({ date: d, value: v.valorisation_nette });
}
for (const k in map) map[k].sort((a, b) => a.date.localeCompare(b.date));
return map;
}, [valorisations]);
const getValorisationAtEndOfMonth = (deal, Y, M) => {
const endM = endOfMonth(Y, M);
if (deal.date_souscription > endM) return 0;
let val = deal.montant_investi;
for (const v of (valorisationsByDeal[deal.id] || [])) {
if (v.date <= endM) val = v.value; else break;
}
return val;
};
/* ── Agrégat par plateforme ── */
const { grid, multiDetenteur } = useMemo(() => {
if (!rows.length) return { grid: null, multiDetenteur: false };
const platMap = {};
for (const p of plats) platMap[p.id] = p;
const byPlat = {};
for (const deal of rows) {
const pid = deal.plateforme_id;
if (!byPlat[pid]) {
const p = platMap[pid] || {};
byPlat[pid] = {
id: pid,
nom: deal.plateforme_nom || p.nom || '—',
investisseur_id: p.investisseur_id ?? null,
detenteur_nom: deal.investisseur_nom || null,
months: Array(12).fill(0),
};
}
const row = byPlat[pid];
for (let m = 1; m <= 12; m++) row.months[m-1] += getValorisationAtEndOfMonth(deal, annee, m);
}
const allPlats = Object.values(byPlat).filter(p => p.months.some(v => v > 0));
const multi = new Set(allPlats.map(p => p.investisseur_id).filter(v => v != null)).size > 1;
const grid = allPlats.sort((a,b) => b.months.reduce((s,v) => s+v, 0) - a.months.reduce((s,v) => s+v, 0));
return { grid, multiDetenteur: multi };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows, annee, valorisationsByDeal, plats]);
const stats = useMemo(() => {
if (!grid) return null;
const monthTotals = Array.from({ length: 12 }, (_, i) => grid.reduce((s, row) => s + row.months[i], 0));
const platMoyennes = grid.map(row => {
const nonZero = row.months.filter(v => v > 0);
return nonZero.length ? nonZero.reduce((s,v) => s+v, 0) / nonZero.length : 0;
});
const totalMoyenne = platMoyennes.reduce((s,v) => s+v, 0);
const platPoids = platMoyennes.map(m => totalMoyenne > 0 ? (m / totalMoyenne) * 100 : 0);
const nonZeroMonthTotals = monthTotals.filter(v => v > 0);
const globalMoyenne = nonZeroMonthTotals.length
? nonZeroMonthTotals.reduce((s,v) => s+v, 0) / nonZeroMonthTotals.length
: 0;
return { monthTotals, platMoyennes, platPoids, globalMoyenne };
}, [grid]);
const [windowStart, setWindowStart] = useState(() => {
const idx = availableYears.indexOf(currentYear);
return Math.max(0, Math.min(Math.max(0, availableYears.length - 3), (idx >= 0 ? idx : availableYears.length - 1) - 1));
});
const visibleYears = availableYears.length ? availableYears.slice(windowStart, windowStart + 3) : [annee];
const canPrev = windowStart > 0;
const canNext = windowStart + 3 < availableYears.length;
return (
<div className="solde-chart-wrap" style={{ padding: '24px 24px 16px', marginBottom: 24 }}>
{/* ── Header ── */}
<div className="solde-chart-header">
<div className="solde-chart-info">
<div style={{ display:'flex', alignItems:'center', gap:5, marginBottom:2 }}>
<span style={{ fontSize:13, color:'var(--text-muted)' }}>{`Valorisation · ${annee}`}</span>
</div>
<div className="solde-chart-value">
{stats ? fmtEUR(stats.globalMoyenne) : '—'}
<span style={{ fontSize:14, fontWeight:400, color:'var(--text-muted)', marginLeft:8 }}>moy. mensuelle</span>
</div>
</div>
<div className="solde-chart-controls">
<div className="solde-chart-ranges">
<button className="solde-range-btn"
onClick={() => setWindowStart(w => Math.max(0, w-1))}
disabled={!canPrev} style={{ opacity: canPrev ? 1 : 0.3 }}>‹</button>
{visibleYears.map(y => (
<button key={y}
className={`solde-range-btn${annee === y ? ' active' : ''}`}
onClick={() => setAnnee(y)}>
{y}
</button>
))}
<button className="solde-range-btn"
onClick={() => setWindowStart(w => Math.min(Math.max(0, availableYears.length - 3), w+1))}
disabled={!canNext} style={{ opacity: canNext ? 1 : 0.3 }}>›</button>
<button className={`solde-range-btn${annee === currentYear ? ' active' : ''}`}
onClick={() => setAnnee(currentYear)}>
TOUT
</button>
{expandButton}
</div>
</div>
</div>
{/* ── Table ── */}
{!grid || grid.length === 0 ? (
<div style={{ marginTop: 20, color: 'var(--text-muted)', fontSize: 'var(--fs-sm)', padding: '24px 0', textAlign: 'center' }}>
Aucune valorisation pour {annee}.
</div>
) : (
<div style={{ overflowX: 'auto', marginTop: 20, position: 'relative', zIndex: 0 }}>
<table className="tip-table">
<thead>
<tr>
<th className="tip-th-empty" />
<th className="tip-th-year" colSpan={12}>{annee}</th>
<th className="tip-th-empty" />
<th className="tip-th-empty" />
</tr>
<tr>
<th className="tip-th-name tip-th-name-amber">Plateforme</th>
{MOIS_LONG.map((m, i) => (
<th key={m}
className={`tip-th-month${annee === currentYear && i === currentMonth - 1 ? ' tip-th-month-current' : ''}`}>
{m}
</th>
))}
<th className="tip-th-total">Moyenne</th>
<th className="tip-th-avg">Poids</th>
</tr>
</thead>
<tbody>
{grid.map((plat, pi) => (
<tr key={plat.id} className="tip-row-plat">
<td className="tip-td-name">
{plat.nom}
{multiDetenteur && plat.detenteur_nom && (
<span style={{ marginLeft: 6, fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>
{plat.detenteur_nom}
</span>
)}
</td>
{plat.months.map((v, mi) => (
<td key={mi}
className={`tip-td-num${annee === currentYear && mi === currentMonth - 1 ? ' tip-col-current' : ''}`}>
{v > 0 ? fmtEUR(v) : <span className="tip-dash">—</span>}
</td>
))}
<td className="tip-td-total">
{stats.platMoyennes[pi] > 0 ? fmtEUR(stats.platMoyennes[pi]) : <span className="tip-dash">—</span>}
</td>
<td className="tip-td-avg">
{stats.platPoids[pi] > 0 ? (
<div style={{ display:'flex', alignItems:'center', gap:5, justifyContent:'flex-end' }}>
<div style={{ width:36, height:4, borderRadius:2, background:'var(--surface-2)', overflow:'hidden' }}>
<div style={{ width:`${Math.min(100,stats.platPoids[pi])}%`, height:'100%', background:'var(--primary)', borderRadius:2 }} />
</div>
<span style={{ minWidth:38, textAlign:'right' }}>{stats.platPoids[pi].toFixed(1)} %</span>
</div>
) : <span className="tip-dash">—</span>}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="tip-footer-total">
<td className="tip-td-name">Toutes les plateformes</td>
{stats.monthTotals.map((v, i) => (
<td key={i}
className={`tip-td-num${annee === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`}>
{v > 0 ? fmtEUR(v) : <span className="tip-dash">—</span>}
</td>
))}
<td className="tip-td-total">{fmtEUR(stats.globalMoyenne)}</td>
<td className="tip-td-void" />
</tr>
</tfoot>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,333 @@
import { useMemo, useState, useRef } from 'react';
/* ── Constantes ─────────────────────────────────────────────── */
const BLUE = 'var(--primary)';
const BG = 'var(--surface)';
const GRID = 'var(--border)';
const LABEL = 'var(--text-muted)';
const RANGES = ['1J', '7J', '1M', '3M', 'YTD', '1A', 'TOUT'];
const MOIS_COURT = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.'];
const W = 900, H = 260;
const PAD = { top: 16, right: 16, bottom: 32, left: 70 };
const plotW = W - PAD.left - PAD.right;
const plotH = H - PAD.top - PAD.bottom;
/* ── Helpers ────────────────────────────────────────────────── */
function fmtK(v) {
if (v === 0) return '0 €';
const abs = Math.abs(v);
if (abs >= 1000) return (v / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' k €';
return v.toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' €';
}
function fmtAxisDate(dateStr, range) {
const d = new Date(dateStr + 'T00:00:00');
const day = String(d.getDate()).padStart(2, '0');
const mon = MOIS_COURT[d.getMonth()];
const yr = d.getFullYear();
if (range === '1J' || range === '7J') return `${day}/${String(d.getMonth()+1).padStart(2,'0')}`;
if (range === '1M' || range === '3M') return `${day} ${mon}`;
return `${day}/${String(d.getMonth()+1).padStart(2,'0')}/${String(yr).slice(2)}`;
}
function fmtValueDisplay(v) {
return v.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }) + ' €';
}
/**
* Graphique "Valorisation totale du portefeuille" — équivalent Private
* Equity de InvChart.jsx (crowdlending), mais avec une sémantique
* radicalement différente (décision Olivier du 17/09/26) : là où InvChart
* cumule des deltas (capital en cours = investi − remboursé), ce composant
* agrège pour chaque deal sa DERNIÈRE valeur connue à une date donnée
* (montant_investi à la souscription, puis chaque valorisation saisie la
* remplace — jamais un delta additif), et somme ces dernières valeurs
* connues sur l'ensemble des deals. La courbe peut donc monter ET
* descendre au fil des valorisations, contrairement au capital en cours
* qui ne fait que décroître.
*/
export default function ValorisationPeChart({ rows, valorisations, platYear }) {
const [range, setRange] = useState('TOUT');
const [hover, setHover] = useState(null);
const svgRef = useRef(null);
const wrapRef = useRef(null);
const todayStr = new Date().toISOString().slice(0, 10);
const currentYear = String(new Date().getFullYear());
const cutoffStr = platYear && platYear !== currentYear ? `${platYear}-12-31` : todayStr;
const dataCutoffStr = platYear && platYear !== currentYear ? `${platYear}-12-31` : null;
/* ── 1. Courbe valorisation totale = somme, par deal, de la dernière
* valeur connue (montant_investi à la souscription, puis remplacée par
* chaque valorisation saisie) ── */
const allPoints = useMemo(() => {
if (!rows?.length) return [];
const rowIds = new Set(rows.map(r => r.id));
// Événements : chaque événement FIXE la valeur connue du deal (et non
// un delta cumulé comme InvChart) — une valorisation remplace la
// précédente valeur connue, elle ne s'y ajoute pas.
const events = [];
for (const r of rows) {
const d = r.date_souscription?.slice(0, 10);
if (!d || (dataCutoffStr && d > dataCutoffStr)) continue;
events.push({ date: d, dealId: r.id, value: r.montant_investi ?? 0 });
}
if (valorisations?.length) {
for (const v of valorisations) {
if (!rowIds.has(v.investissement_pe_id)) continue;
const d = v.date_valorisation?.slice(0, 10);
if (!d || v.valorisation_nette == null || (dataCutoffStr && d > dataCutoffStr)) continue;
events.push({ date: d, dealId: v.investissement_pe_id, value: v.valorisation_nette });
}
}
events.sort((a, b) => a.date.localeCompare(b.date) || a.dealId - b.dealId);
const lastValueByDeal = {};
let cum = 0;
const cumulated = [];
let i = 0;
while (i < events.length) {
const d = events[i].date;
while (i < events.length && events[i].date === d) {
const ev = events[i];
cum += ev.value - (lastValueByDeal[ev.dealId] ?? 0);
lastValueByDeal[ev.dealId] = ev.value;
i++;
}
cumulated.push({ date: d, value: Math.max(0, cum) });
}
// Un point dont la date dépasse aujourd'hui est regroupé sur la date
// du jour pour l'affichage (identique à InvChart).
const clamped = [];
for (const p of cumulated) {
const d = p.date > todayStr ? todayStr : p.date;
if (clamped.length && clamped[clamped.length - 1].date === d) {
clamped[clamped.length - 1] = { date: d, value: p.value };
} else {
clamped.push({ date: d, value: p.value });
}
}
return clamped;
}, [rows, valorisations, dataCutoffStr, todayStr]);
const currentValue = allPoints.length ? allPoints[allPoints.length - 1].value : 0;
/* ── 2. Filtrage par plage ── */
const points = useMemo(() => {
if (!allPoints.length) return [];
if (range === 'TOUT') {
const pts = [...allPoints];
if (pts[pts.length - 1].date < cutoffStr) pts.push({ date: cutoffStr, value: pts[pts.length - 1].value });
return pts;
}
const now = new Date(cutoffStr + 'T00:00:00');
let fromDate = new Date(now);
switch (range) {
case '1J': fromDate.setDate(now.getDate() - 1); break;
case '7J': fromDate.setDate(now.getDate() - 7); break;
case '1M': fromDate.setMonth(now.getMonth() - 1); break;
case '3M': fromDate.setMonth(now.getMonth() - 3); break;
case 'YTD': fromDate = new Date(now.getFullYear(), 0, 1); break;
case '1A': fromDate.setFullYear(now.getFullYear() - 1); break;
}
const fromStr = fromDate.toISOString().slice(0, 10);
const before = allPoints.filter(p => p.date < fromStr);
const startV = before.length ? before[before.length - 1].value : 0;
const after = allPoints.filter(p => p.date >= fromStr);
const pts = [{ date: fromStr, value: startV }, ...after];
if (pts[pts.length - 1].date < cutoffStr) pts.push({ date: cutoffStr, value: pts[pts.length - 1].value });
return pts;
}, [allPoints, range, cutoffStr]);
/* ── 3. Échelles ── */
const { xScale, yScale, yTicks, xTicks, minDate, maxDate, yZero } = useMemo(() => {
if (points.length < 2) return {};
const vals = points.map(p => p.value);
const dataMin = Math.min(...vals);
const dataMax = Math.max(...vals);
const lo = Math.min(0, dataMin);
const hi = Math.max(0, dataMax);
const pad = (hi - lo) * 0.1 || 10;
const scaleLo = lo - (lo < 0 ? pad : 0);
const scaleHi = hi + pad;
const valRange = scaleHi - scaleLo || 1;
const ts = points.map(p => new Date(p.date + 'T00:00:00').getTime());
const minDt = ts[0];
const maxDt = ts[ts.length - 1];
const dtRange = maxDt - minDt || 1;
const xScale = d => PAD.left + ((new Date(d + 'T00:00:00').getTime() - minDt) / dtRange) * plotW;
const yScale = v => PAD.top + plotH - ((v - scaleLo) / valRange) * plotH;
const step = (scaleHi - scaleLo) / 4;
const yTicks = Array.from({ length: 5 }, (_, i) => ({ v: scaleLo + step * i, y: yScale(scaleLo + step * i) }));
const nX = Math.min(8, points.length);
const xTicks = Array.from({ length: nX }, (_, i) => {
const idx = Math.round((i / (nX - 1)) * (points.length - 1));
return points[idx];
});
return { xScale, yScale, yTicks, xTicks, minDate: minDt, maxDate: maxDt, yZero: yScale(0) };
}, [points]);
/* ── 4. Chemins SVG ── */
const { linePath, areaPath } = useMemo(() => {
if (!xScale || points.length < 2) return { linePath: '', areaPath: '' };
let line = `M ${xScale(points[0].date).toFixed(1)},${yScale(points[0].value).toFixed(1)}`;
for (let i = 1; i < points.length; i++) {
line += ` H ${xScale(points[i].date).toFixed(1)} V ${yScale(points[i].value).toFixed(1)}`;
}
const zeroY = yZero?.toFixed(1) ?? (PAD.top + plotH).toFixed(1);
const area = `${line} V ${zeroY} H ${PAD.left} Z`;
return { linePath: line, areaPath: area };
}, [points, xScale, yScale, yZero]);
/* ── 5. Hover ── */
const handleMouseMove = (e) => {
if (!svgRef.current || !xScale || points.length < 2) return;
const rect = svgRef.current.getBoundingClientRect();
const svgX = ((e.clientX - rect.left) / rect.width) * W;
const t = minDate + ((svgX - PAD.left) / plotW) * (maxDate - minDate);
let nearest = points[0], minDiff = Infinity;
for (const p of points) {
const diff = Math.abs(new Date(p.date + 'T00:00:00').getTime() - t);
if (diff < minDiff) { minDiff = diff; nearest = p; }
}
setHover({ x: xScale(nearest.date), y: yScale(nearest.value), value: nearest.value, date: nearest.date });
};
/* ── Tooltip ── */
const tooltipStyle = useMemo(() => {
if (!hover) return null;
const xPct = (hover.x / W) * 100;
const yPct = (hover.y / H) * 100;
const anchorRight = xPct > 65;
return {
position: 'absolute',
top: `calc(${yPct}% - 64px)`,
left: anchorRight ? 'auto' : `calc(${xPct}% + 12px)`,
right: anchorRight ? `calc(${100 - xPct}% + 12px)` : 'auto',
transform: 'none',
pointerEvents: 'none',
};
}, [hover]);
if (!allPoints.length) return null;
const displayLabel = platYear ? `31/12/${platYear}` : "Aujourd'hui";
const displayDate = hover
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })
: displayLabel;
const displayValue = fmtValueDisplay(hover ? hover.value : currentValue);
const tooltipDate = hover
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
: null;
return (
<div className="solde-chart-wrap" ref={wrapRef} style={{ position: 'relative' }}>
{/* ── En-tête ── */}
<div className="solde-chart-header">
<div className="solde-chart-info">
<div className="solde-chart-date">Valorisation totale · {displayDate}</div>
<div className="solde-chart-value">{displayValue}</div>
</div>
<div className="solde-chart-controls">
<div className="solde-chart-ranges">
{RANGES.map(r => (
<button key={r}
className={`solde-range-btn${range === r ? ' active' : ''}`}
onClick={() => { setRange(r); setHover(null); }}>
{r}
</button>
))}
</div>
</div>
</div>
{/* ── SVG ── */}
{xScale && (
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
style={{ width: '100%', height: 'auto', display: 'block', cursor: 'crosshair' }}
onMouseMove={handleMouseMove}
onMouseLeave={() => setHover(null)}
>
<defs>
<linearGradient id="pe-val-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={BLUE} stopOpacity="0.22" />
<stop offset="70%" stopColor={BLUE} stopOpacity="0.06" />
<stop offset="100%" stopColor={BLUE} stopOpacity="0" />
</linearGradient>
<filter id="pe-val-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
{/* Grille horizontale */}
{yTicks.map(({ v, y }) => (
<g key={v}>
<line x1={PAD.left} y1={y} x2={W - PAD.right} y2={y}
stroke={GRID} strokeWidth="1" strokeDasharray="3 5" />
<text x={PAD.left - 10} y={y + 4} textAnchor="end"
fill={LABEL} fontSize="11" fontFamily="system-ui,sans-serif">
{fmtK(v)}
</text>
</g>
))}
{/* Ligne zéro */}
{yZero && yZero > PAD.top && yZero < PAD.top + plotH && (
<line x1={PAD.left} y1={yZero} x2={W - PAD.right} y2={yZero}
stroke={GRID} strokeWidth="1" />
)}
{/* Aire dégradée + courbe */}
<path d={areaPath} fill="url(#pe-val-fill)" />
<path d={linePath} fill="none" stroke={BLUE} strokeWidth="1.5"
filter="url(#pe-val-glow)" strokeLinejoin="round" />
{/* Labels axe X */}
{xTicks.map((p, i) => {
const anchor = i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle';
return (
<text key={i} x={xScale(p.date)} y={H - 8}
textAnchor={anchor} fill={LABEL} fontSize="10"
fontFamily="system-ui,sans-serif">
{fmtAxisDate(p.date, range)}
</text>
);
})}
{/* Hover : ligne verticale + point */}
{hover && (
<g>
<line x1={hover.x} y1={PAD.top} x2={hover.x} y2={PAD.top + plotH}
stroke={GRID} strokeWidth="1" strokeDasharray="3 4" />
<circle cx={hover.x} cy={hover.y} r="4.5"
fill={BLUE} stroke={BG} strokeWidth="2" />
</g>
)}
</svg>
)}
{/* ── Tooltip flottant ── */}
{hover && tooltipStyle && (
<div style={tooltipStyle}>
<div className="sg-tooltip">
<span className="sg-tooltip-date">{tooltipDate}</span>
<span className="sg-tooltip-value">{fmtValueDisplay(hover.value)}</span>
</div>
</div>
)}
</div>
);
}
+5 -1
View File
@@ -78,9 +78,13 @@ export function AuthProvider({ children }) {
// simple assumé : n'apparaît qu'après rechargement de session suivant la toute première
// déclaration de frais, pas de mise à jour réactive en direct.
const hasFrais = !!user?.has_frais;
// Équivalent PE (chantier "frais PE", 17/09/26) — vrai dès qu'au moins un frais a été
// déclaré sur un deal Private Equity de l'utilisateur (calculé côté backend, cf.
// GET /auth/me, colonne has_frais_pe). Même mécanisme non réactif que hasFrais.
const hasFraisPe = !!user?.has_frais_pe;
return (
<AuthCtx.Provider value={{ token, user, loading, login, completeLogin, register, logout, updateUser, deleteAccount, isAdmin, hasFrais }}>
<AuthCtx.Provider value={{ token, user, loading, login, completeLogin, register, logout, updateUser, deleteAccount, isAdmin, hasFrais, hasFraisPe }}>
{children}
</AuthCtx.Provider>
);
+17 -4
View File
@@ -104,6 +104,15 @@ export function UiProvider({ children }) {
localStorage.getItem(KEY_ADMIN_COLOR_THEME) || 'classique'
);
// Couleur d'accent propre à l'espace de travail actif (17/09/26, cf.
// AppearanceSection.jsx / WorkspaceContext.jsx) : null si l'utilisateur n'a
// qu'un seul espace actif, ou si l'espace actif hérite de la couleur de
// l'application. Non persistée ici (c'est user_workspaces.color_theme, via
// WorkspaceContext, qui fait foi) — juste reflétée dans l'attribut
// data-color-theme par Layout.jsx à chaque changement d'espace actif.
// Prime sur colorTheme/adminColorTheme quand elle est définie.
const [workspaceColorOverride, setWorkspaceColorOverride] = useState(null);
// ── Thème par défaut (admin) : chargé une fois au montage ───────
// Endpoint public (pas d'auth requise) : reste valable sur la page de
// connexion, comme dans App.jsx.
@@ -116,10 +125,14 @@ export function UiProvider({ children }) {
}, []);
// ── Applique le thème de couleur effectif sur <html> ────────────
// Un choix personnel (colorTheme) prime toujours sur le défaut admin.
// Ordre de priorité : couleur du workspace actif (si définie) > choix
// personnel (colorTheme) > défaut admin.
useEffect(() => {
document.documentElement.setAttribute('data-color-theme', colorTheme || adminColorTheme);
}, [colorTheme, adminColorTheme]);
document.documentElement.setAttribute(
'data-color-theme',
workspaceColorOverride || colorTheme || adminColorTheme
);
}, [colorTheme, adminColorTheme, workspaceColorOverride]);
// ── Sync DB → localStorage au montage ──────────────────────────
// La DB fait foi : ses valeurs écrasent le localStorage si présentes.
@@ -253,7 +266,7 @@ export function UiProvider({ children }) {
}, [persistPref]);
return (
<UiCtx.Provider value={{ sidebarCollapsed, setSidebarCollapsed, toggleSidebar, fontScale, setFontScale, langue, setLangue, devise, setDevise, displayMode, setDisplayMode, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback, pfoAssujetti, setPfoAssujetti, objectifsActifs, setObjectifsActifs, colorTheme, adminColorTheme, setColorTheme }}>
<UiCtx.Provider value={{ sidebarCollapsed, setSidebarCollapsed, toggleSidebar, fontScale, setFontScale, langue, setLangue, devise, setDevise, displayMode, setDisplayMode, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback, pfoAssujetti, setPfoAssujetti, objectifsActifs, setObjectifsActifs, colorTheme, adminColorTheme, setColorTheme, workspaceColorOverride, setWorkspaceColorOverride }}>
{children}
</UiCtx.Provider>
);
+10 -1
View File
@@ -63,6 +63,15 @@ export function WorkspaceProvider({ children }) {
api.patch('/preferences', { active_workspace_id: String(id) }).catch(() => {});
}, []);
// Couleur d'accent personnelle par workspace (17/09/26, cf. AppearanceSection.jsx) —
// null = hérite de la couleur de l'application (UiContext.jsx). Mise à jour
// optimiste locale + persistance en base (user_workspaces.color_theme,
// routes/workspaces.js PATCH /:id/color).
const setWorkspaceColor = useCallback((workspaceId, value) => {
setWorkspaces(ws => ws.map(w => (w.id === workspaceId ? { ...w, color_theme: value } : w)));
api.patch(`/workspaces/${workspaceId}/color`, { color_theme: value }).catch(() => {});
}, []);
// Seuls les workspaces activés personnellement (active_by_user) comptent
// pour l'affichage — un workspace accordé par l'admin mais désactivé par
// l'utilisateur (Settings > Mes espaces de travail) reste invisible.
@@ -76,7 +85,7 @@ export function WorkspaceProvider({ children }) {
return (
<WorkspaceCtx.Provider value={{
workspaces, activeWorkspaces, active, activeId,
setActiveWorkspace, reload, loading,
setActiveWorkspace, setWorkspaceColor, reload, loading,
hasMultipleWorkspaces: activeWorkspaces.length > 1,
}}>
{children}
+50
View File
@@ -0,0 +1,50 @@
import { useCallback, useRef, useState } from 'react';
/**
* useAsyncAction — garde anti-double-exécution pour un handler asynchrone
* (soumission de formulaire, suppression, action d'un bouton…).
*
* Contexte : sur une connexion lente vers le serveur (VPN, 4G…), rien
* n'indiquait visuellement qu'une requête était en cours. L'utilisateur,
* ne voyant pas de retour, cliquait une seconde fois sur "Créer"/"Enregistrer"
* avant la fermeture de la modale (qui n'intervient qu'après le succès de la
* requête) — ce qui déclenchait un second POST et doublait la saisie en base.
*
* `run` protège contre ce phénomène à deux niveaux :
* 1. Une ref (donc synchrone, pas soumise au re-render React) bloque tout
* appel concurrent tant qu'un premier est en cours — même un double-clic
* très rapproché, avant que le bouton n'ait eu le temps de se désactiver
* visuellement, est ignoré.
* 2. `pending` (état React) permet en plus de désactiver l'UI (bouton,
* fermeture de la modale) et d'afficher un indicateur de chargement,
* pour que l'utilisateur sache que sa requête est bien partie.
*
* Usage :
* const { run, pending } = useAsyncAction();
* ...
* <button disabled={pending} onClick={() => run(submit)}>
* {pending ? 'Enregistrement…' : 'Enregistrer'}
* </button>
*
* `run` transmet ses arguments au handler (utile pour un onSubmit de <form>
* qui reçoit l'événement) et retourne sa valeur de résolution. Un appel
* ignoré (déjà en cours) résout silencieusement sur `undefined`.
*/
export function useAsyncAction() {
const runningRef = useRef(false);
const [pending, setPending] = useState(false);
const run = useCallback(async (handler, ...args) => {
if (runningRef.current) return undefined;
runningRef.current = true;
setPending(true);
try {
return await handler(...args);
} finally {
runningRef.current = false;
setPending(false);
}
}, []);
return { run, pending };
}
+1 -1
View File
@@ -397,7 +397,7 @@ function TauxCreditImpotSection() {
)}
{editing && <TciModal row={editing === 'new' ? null : editing} onClose={() => setEditing(null)} onSaved={load} />}
{deleting && <ConfirmModal open title={deleting.title} message={deleting.message} confirmLabel={deleting.confirmLabel} danger={deleting.danger} onConfirm={deleting.onConfirm} onClose={() => setDeleting(null)} />}
{deleting && <ConfirmModal open title={deleting.title} message={deleting.message} confirmLabel={deleting.confirmLabel} danger={deleting.danger} onConfirm={deleting.onConfirm} onCancel={() => setDeleting(null)} />}
</div>
);
}
+67 -19
View File
@@ -46,13 +46,14 @@ const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code
const FISCALITE_LABELS = { flat_tax: 'Flat tax (30%)', sans_fiscalite_locale: 'Sans fiscalité locale', avec_fiscalite_locale: 'Avec fiscalité locale' };
const CATEGORIE_PLATEFORME_LABELS = { psfp: 'Prestataire de Services de Financement Participatif', psan: 'Prestataire de Services sur Actifs Numériques' };
const FRAIS_PLATEFORME_LABELS = { aucun: 'Aucun frais', entree_gestion_directe: "Frais d'entrée + Frais de gestion (Prélèvement direct)" };
const EMPTY_REF = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', categorie_plateforme: 'psfp', frais_plateforme: 'aucun', methode_remboursement: 'portefeuille', type_pret_defaut: '', freq_interets_defaut: '', description: '', categories: [], categories_inv_ids: [], secteurs_inv_ids: [] };
const EMPTY_REF = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', categorie_plateforme: 'psfp', frais_plateforme: 'aucun', methode_remboursement: 'portefeuille', type_pret_defaut: '', freq_interets_defaut: '', description: '', categories: [], categories_inv_ids: [], secteurs_inv_ids: [], workspace_ids: [] };
/* ── Modal création / édition référentiel ─────────────────────── */
function RefModal({ row, onClose, onSaved }) {
const isNew = !row?.id;
const [catsInv, setCatsInv] = useState([]);
const [secteursInv, setSecteursInv] = useState([]);
const [workspaces, setWorkspaces] = useState([]);
const [err, setErr] = useState(null);
const [saving, setSaving] = useState(false);
@@ -70,15 +71,30 @@ function RefModal({ row, onClose, onSaved }) {
categories: [],
categories_inv_ids: (row.categories_inv || []).map(c => c.id),
secteurs_inv_ids: (row.secteurs_inv || []).map(s => s.id),
workspace_ids: (row.workspaces || []).map(w => w.id),
});
useEffect(() => {
Promise.all([
api.get('/ref-categories'),
api.get('/ref-secteurs'),
]).then(([cats, sects]) => {
api.get('/admin/workspaces'),
]).then(([cats, sects, ws]) => {
setCatsInv(cats);
setSecteursInv(sects);
setWorkspaces(ws);
// À la création, présélectionne le workspace "crowdlending" (seul
// workspace réel aujourd'hui) — l'utilisateur reste libre d'ajuster ;
// au moins une affectation reste exigée à l'enregistrement.
if (isNew) {
setForm(f => (f.workspace_ids && f.workspace_ids.length > 0) ? f : {
...f,
workspace_ids: (() => {
const cl = ws.find(w => w.slug === 'crowdlending');
return cl ? [cl.id] : ws.map(w => w.id);
})(),
});
}
}).catch(() => {});
}, []); // eslint-disable-line
@@ -88,6 +104,7 @@ function RefModal({ row, onClose, onSaved }) {
const handleSubmit = async (e) => {
e.preventDefault();
if (!form.nom.trim()) { setErr('Le nom est requis.'); return; }
if (!form.workspace_ids || form.workspace_ids.length === 0) { setErr('Sélectionnez au moins un espace de travail.'); return; }
setSaving(true); setErr(null);
try {
// Champs gérés par RefModal
@@ -105,6 +122,7 @@ function RefModal({ row, onClose, onSaved }) {
description: form.description.trim() || null,
categories_inv_ids: form.categories_inv_ids || [],
secteurs_inv_ids: form.secteurs_inv_ids || [],
workspace_ids: form.workspace_ids || [],
};
if (isNew) {
@@ -165,6 +183,26 @@ function RefModal({ row, onClose, onSaved }) {
<option value="psan">{CATEGORIE_PLATEFORME_LABELS.psan}</option>
</select>
</div>
<div>
<label>Espaces de travail *</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{workspaces.map(w => {
const checked = (form.workspace_ids || []).includes(w.id);
return (
<label key={w.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input type="checkbox" checked={checked} style={{ width: 'auto' }}
onChange={e => set('workspace_ids', e.target.checked
? [...(form.workspace_ids || []), w.id]
: (form.workspace_ids || []).filter(id => id !== w.id))} />
<span className="chip-cat" style={{ fontSize: 11 }}>{w.nom}</span>
</label>
);
})}
</div>
<div className="text-muted" style={{ fontSize: 11, marginTop: 4 }}>
Cette entrée du référentiel ne sera visible/utilisable que dans le(s) espace(s) de travail coché(s).
</div>
</div>
<div><label>Description</label>
<textarea value={form.description} onChange={e => set('description', e.target.value)}
style={{ minHeight: 180, resize: 'vertical' }} placeholder="Informations générales sur la plateforme…" /></div>
@@ -327,6 +365,7 @@ function ReferentielSection() {
const { search: urlSearch } = useLocation();
const [search, setSearch] = useState('');
const [filterDomicil, setFilterDomicil] = useState('');
const [filterWorkspace, setFilterWorkspace] = useState('');
const [filterCategorie, setFilterCategorie] = useState(
() => new URLSearchParams(urlSearch).get('filterCat') || ''
);
@@ -337,6 +376,12 @@ function ReferentielSection() {
return [...set].sort((a, b) => a.localeCompare(b, 'fr'));
}, [rows]);
const allWorkspaces = useMemo(() => {
const map = new Map();
rows.forEach(r => (r.workspaces || []).forEach(w => map.set(w.id, w.nom)));
return [...map.entries()].sort((a, b) => a[1].localeCompare(b[1], 'fr'));
}, [rows]);
const allDomiciliations = useMemo(() => {
const set = new Set();
rows.forEach(r => { if (r.domiciliation) set.add(r.domiciliation); });
@@ -346,16 +391,17 @@ function ReferentielSection() {
const filteredRows = useMemo(() => {
let r = rows;
if (filterDomicil) r = r.filter(x => x.domiciliation === filterDomicil);
if (filterWorkspace) r = r.filter(x => (x.workspaces || []).some(w => String(w.id) === filterWorkspace));
if (filterCategorie) r = r.filter(x => (x.categories_inv || []).some(c => c.nom === filterCategorie));
if (search.trim()) {
const q = search.trim().toLowerCase();
r = r.filter(x => x.nom.toLowerCase().includes(q));
}
return r;
}, [rows, search, filterDomicil, filterCategorie]);
}, [rows, search, filterDomicil, filterWorkspace, filterCategorie]);
const { pagedItems, page, setPage, pageSize, setPageSize, totalPages, totalItems, PAGE_SIZES } =
usePagination(filteredRows, 'cl_pagesize_referentiel', [search, filterDomicil, filterCategorie]);
usePagination(filteredRows, 'cl_pagesize_referentiel', [search, filterDomicil, filterWorkspace, filterCategorie]);
const load = useCallback(async () => {
try {
@@ -486,6 +532,16 @@ function ReferentielSection() {
style={{ width: '100%', boxSizing: 'border-box', padding: '9px 14px', fontSize: 14, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)' }}
/>
<div style={{ display: 'flex', gap: 8 }}>
<select
value={filterWorkspace}
onChange={e => setFilterWorkspace(e.target.value)}
style={{ flex: 1, padding: '7px 12px', fontSize: 13, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)' }}
>
<option value="">Tous les espaces de travail</option>
{allWorkspaces.map(([id, nom]) => (
<option key={id} value={String(id)}>{nom}</option>
))}
</select>
<select
value={filterDomicil}
onChange={e => setFilterDomicil(e.target.value)}
@@ -504,9 +560,9 @@ function ReferentielSection() {
<option value="">Toutes les catégories</option>
{allCategories.map(c => <option key={c} value={c}>{c}</option>)}
</select>
{(search || filterDomicil || filterCategorie) && (
{(search || filterDomicil || filterWorkspace || filterCategorie) && (
<button
onClick={() => { setSearch(''); setFilterDomicil(''); setFilterCategorie(''); }}
onClick={() => { setSearch(''); setFilterDomicil(''); setFilterWorkspace(''); setFilterCategorie(''); }}
style={{ padding: '7px 12px', fontSize: 13, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'pointer', whiteSpace: 'nowrap' }}
>Effacer les filtres</button>
)}
@@ -537,15 +593,14 @@ function ReferentielSection() {
<th style={{ padding: '10px 8px', width: 40 }}></th>
<th style={{ padding: '10px 16px', textAlign: 'left' }}>Nom</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Domiciliation</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Catégories</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Secteurs</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Workspaces</th>
<th style={{ padding: '10px 8px', textAlign: 'center' }}>Héritage</th>
<th style={{ padding: '10px 16px', width: 40 }}></th>
</tr>
</thead>
<tbody>
{filteredRows.length === 0 ? (
<tr><td colSpan={7} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
<tr><td colSpan={6} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
{rows.length === 0
? 'Aucune entrée dans le référentiel. Créez-en une ou importez une plateforme orpheline.'
: 'Aucun résultat pour ces critères.'}
@@ -573,16 +628,9 @@ function ReferentielSection() {
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}><span style={{ display:'inline-flex', alignItems:'center', gap:5 }}><FlagIcon code={row.domiciliation} size={15} />{countryLabel(row.domiciliation)}</span></td>
<td style={{ padding: '10px 8px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{(row.categories_inv || []).slice(0, 2).map(c => <span key={c.id} className="chip-cat" style={{ fontSize: 11 }}>{c.nom}</span>)}
{(row.categories_inv || []).length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{row.categories_inv.length - 2}</span>}
{(row.categories_inv || []).length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
</div>
</td>
<td style={{ padding: '10px 8px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{(row.secteurs_inv || []).slice(0, 2).map(s => <span key={s.id} className="chip-sect" style={{ fontSize: 11 }}>{s.nom}</span>)}
{(row.secteurs_inv || []).length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{row.secteurs_inv.length - 2}</span>}
{(row.secteurs_inv || []).length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
{(row.workspaces || []).slice(0, 2).map(w => <span key={w.id} className="chip-workspace" style={{ fontSize: 11 }}>{w.nom}</span>)}
{(row.workspaces || []).length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{row.workspaces.length - 2}</span>}
{(row.workspaces || []).length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
</div>
</td>
<td style={{ padding: '10px 8px', textAlign: 'center' }}>
+331
View File
@@ -0,0 +1,331 @@
import { useEffect, useMemo, useState } from 'react';
import PageIcon from '../components/PageIcon.jsx';
import EmptyState from '../components/EmptyState.jsx';
import ValorisationPeChart from '../components/ValorisationPeChart.jsx';
import { FRAIS_CATEGORIE_LABELS } from '../components/MouvementsShared.jsx';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useWorkspace } from '../context/WorkspaceContext.jsx';
import { fmtEUR, fmtPct } from '../utils/format.js';
import { xirr } from '../utils/xirr.js';
/**
* Tableau de bord Private Equity (chantier "dashboard PE", 17/09/26) — première
* version structurelle demandée par Olivier ("un premier jet avec tout") : KPI
* de portefeuille (porte-monnaie, capital investi, NAV, TVPI/DPI/RVPI, TRI,
* frais cumulés, fraîcheur des valorisations) + graphiques (courbe de
* valorisation globale, répartitions par plateforme/stratégie, frais par
* catégorie) + classement des deals par performance. Réutilise
* ValorisationPeChart (déjà utilisé sur la page Investissements PE) pour la
* courbe, et GET /api/dashboard-pe (déjà monté côté backend, jusqu'ici
* consommé uniquement par DepotsRetraitsSimple.jsx) pour le solde du
* porte-monnaie — même snapshot que le Dashboard crowdlending. Les
* répartitions passent par un petit composant local BarBreakdown plutôt que
* FraisDistributionChart, dont l'en-tête affiche le mot "Frais" en dur —
* trompeur pour une répartition de capital investi.
*/
const STATUT_LABELS = {
en_attente: 'En attente',
valide: 'Validé',
cloture: 'Clôturé',
perte_definitive: 'Perte définitive',
};
const PALETTE = [
'#1e40af', '#9333ea', '#0d9488', '#f59e0b',
'#e8547a', '#10b981', '#f97316', '#06b6d4',
];
const fmtMultiple = (n) => (n != null ? `x${n.toFixed(2).replace('.', ',')}` : '—');
/* ── Carte KPI ──────────────────────────────────────────────── */
function KpiCard({ title, value, note, color }) {
return (
<div className="kpi">
<div className="label">{title}</div>
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4, color: color || 'var(--text)' }}>{value}</div>
{note && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{note}</div>}
</div>
);
}
/* ── Répartition en barres horizontales (générique, léger) ─────
* items : [{ label, value }] — trié par valeur décroissante, largeur des
* barres proportionnelle au maximum. Volontairement plus simple qu'un
* treemap SVG (DistributionChart / FraisDistributionChart) : ce composant
* reste neutre (pas de titre métier en dur) pour être réutilisé sur
* plusieurs répartitions différentes sans être trompeur. */
function BarBreakdown({ title, items }) {
const data = (items || []).filter(i => i.value > 0).sort((a, b) => b.value - a.value);
const max = data.length ? data[0].value : 0;
const total = data.reduce((s, i) => s + i.value, 0);
return (
<div className="card">
<h3 style={{ margin: '0 0 14px' }}>{title}</h3>
{data.length === 0 && (
<div className="text-muted" style={{ textAlign: 'center', padding: '20px 0' }}>Aucune donnée</div>
)}
{data.map((d, i) => (
<div key={d.label} style={{ marginBottom: 10 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85em', marginBottom: 3 }}>
<span>{d.label}</span>
<span className="text-muted">{fmtEUR(d.value)} · {total > 0 ? Math.round(d.value / total * 100) : 0}%</span>
</div>
<div style={{ background: 'var(--surface-2)', borderRadius: 6, height: 8, overflow: 'hidden' }}>
<div style={{
width: `${max > 0 ? (d.value / max * 100) : 0}%`, height: '100%',
background: PALETTE[i % PALETTE.length], borderRadius: 6,
}} />
</div>
</div>
))}
</div>
);
}
/* ── Mini-jauge TVPI (classement par deal) — échelle visuelle 0 → x3 (au-delà,
* la barre sature à 100% : l'essentiel est de voir d'un coup d'œil qui est
* au-dessus/en-dessous de x1, pas d'avoir une échelle linéaire fidèle à un
* multiple hors norme). ── */
function TvpiBar({ value }) {
if (value == null) return <span className="text-muted">—</span>;
const pct = Math.min(value, 3) / 3 * 100;
const color = value >= 1 ? 'var(--success)' : 'var(--danger)';
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ position: 'relative', width: 90, height: 8, background: 'var(--surface-2)', borderRadius: 4, flexShrink: 0 }}>
<div style={{ position: 'absolute', inset: 0, width: `${pct}%`, background: color, borderRadius: 4 }} />
<div title="x1 (seuil de rentabilité)" style={{ position: 'absolute', left: `${(1 / 3) * 100}%`, top: -2, width: 1, height: 12, background: 'var(--text-muted)' }} />
</div>
<span style={{ fontWeight: 600, color }}>{fmtMultiple(value)}</span>
</div>
);
}
export default function DashboardPe() {
const { activeId: investisseurId } = useInvestisseur();
const { active } = useWorkspace() || {};
const [rows, setRows] = useState([]);
const [valorisations, setValorisations] = useState([]);
const [fraisOps, setFraisOps] = useState([]);
const [rembs, setRembs] = useState([]);
const [wallet, setWallet] = useState(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
useEffect(() => {
if (!investisseurId) return;
setLoading(true);
setErr(null);
Promise.all([
api.get('/investissements-pe'),
api.get('/investissements-pe/valorisations'),
api.get('/frais-operations'),
api.get('/remboursements'),
api.get('/dashboard-pe'),
]).then(([inv, val, frais, remb, dash]) => {
setRows(inv);
setValorisations(val);
setFraisOps(frais);
setRembs(remb);
setWallet(dash);
}).catch(e => setErr(e.message))
.finally(() => setLoading(false));
}, [investisseurId, active?.id]);
/* ── Agrégats KPI ── */
const totals = useMemo(() => {
let investi = 0, nav = 0, distributions = 0, fraisCumules = 0, actifs = 0, soldes = 0;
const staleDeals = [];
const staleThreshold = new Date();
staleThreshold.setMonth(staleThreshold.getMonth() - 6);
const staleThresholdStr = staleThreshold.toISOString().slice(0, 10);
for (const r of rows) {
investi += r.montant_investi;
nav += (r.derniere_valorisation ?? r.montant_investi);
distributions += r.distributions_cumulees || 0;
const enCours = r.statut === 'valide' || r.statut === 'en_attente';
if (enCours) {
actifs++;
if (!r.derniere_valorisation_date || r.derniere_valorisation_date < staleThresholdStr) {
staleDeals.push(r.nom_deal);
}
} else {
soldes++;
}
}
for (const f of fraisOps) fraisCumules += f.montant || 0;
return {
investi, nav, distributions, fraisCumules, actifs, soldes,
staleCount: staleDeals.length, staleDeals,
tvpi: investi > 0 ? (nav + distributions) / investi : null,
dpi: investi > 0 ? distributions / investi : null,
rvpi: investi > 0 ? nav / investi : null,
};
}, [rows, fraisOps]);
/* ── TRI (XIRR) global du portefeuille — même méthode que le XIRR
* crowdlending (Dashboard.jsx) : flux réels datés + valeur résiduelle des
* deals encore actifs, valorisée "comme vendue aujourd'hui". ── */
const xirrData = useMemo(() => {
const cutoff = new Date().toISOString().slice(0, 10);
const flows = [];
for (const r of rows) {
if (!r.date_souscription) continue;
flows.push({ amount: -r.montant_investi, date: r.date_souscription });
}
for (const rb of rembs) {
if (rb.investissement_pe_id == null) continue;
const d = rb.date_remb?.slice(0, 10);
if (!d || d > cutoff) continue;
flows.push({ amount: rb.net_recu || 0, date: d });
}
// Frais réglés hors "source" (vraie sortie de trésorerie du détenteur) —
// même logique que le XIRR net crowdlending.
for (const fo of fraisOps) {
if (fo.mode_reglement !== 'portefeuille' && fo.mode_reglement !== 'compte_courant') continue;
const d = fo.date_operation?.slice(0, 10);
if (!d || d > cutoff) continue;
flows.push({ amount: -(fo.montant || 0), date: d });
}
let estime = false;
for (const r of rows) {
if (r.statut !== 'valide' && r.statut !== 'en_attente') continue;
const val = r.derniere_valorisation ?? r.montant_investi;
if (val > 0.01) { estime = true; flows.push({ amount: val, date: cutoff }); }
}
flows.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
return { rate: xirr(flows), estime };
}, [rows, rembs, fraisOps]);
/* ── Répartitions ── */
const capitalParPlateforme = useMemo(() => {
const map = {};
for (const r of rows) map[r.plateforme_nom] = (map[r.plateforme_nom] || 0) + r.montant_investi;
return Object.entries(map).map(([label, value]) => ({ label, value }));
}, [rows]);
const capitalParStrategie = useMemo(() => {
const map = {};
for (const r of rows) {
const k = r.strategie || 'Non renseignée';
map[k] = (map[k] || 0) + r.montant_investi;
}
return Object.entries(map).map(([label, value]) => ({ label, value }));
}, [rows]);
const fraisParCategorie = useMemo(() => {
const map = {};
for (const f of fraisOps) {
const k = FRAIS_CATEGORIE_LABELS[f.categorie] || f.categorie || 'Autre';
map[k] = (map[k] || 0) + (f.montant || 0);
}
return Object.entries(map).map(([label, value]) => ({ label, value }));
}, [fraisOps]);
/* ── Classement des deals par TVPI ── */
const ranking = useMemo(() => [...rows].sort((a, b) => {
if (a.tvpi == null && b.tvpi == null) return 0;
if (a.tvpi == null) return 1;
if (b.tvpi == null) return -1;
return b.tvpi - a.tvpi;
}), [rows]);
const xirrDisplay = xirrData.rate != null ? fmtPct(xirrData.rate * 100) : 'Non calculable';
const xirrColor = xirrData.rate != null ? (xirrData.rate >= 0 ? 'var(--success)' : 'var(--danger)') : undefined;
const xirrNote = xirrData.rate == null
? 'Pas assez de flux datés pour calculer un taux'
: (xirrData.estime ? 'Estimé — deals actifs valorisés à date du jour' : 'Taux définitif — tous les deals sont soldés');
const staleNote = totals.staleCount === 0
? 'Toutes les valorisations actives sont à jour'
: totals.staleDeals.slice(0, 3).join(', ') + (totals.staleDeals.length > 3 ? '…' : '');
return (
<>
<div className="topbar"><h2><PageIcon name="dashboard" />Tableau de bord — {active?.nom || 'Private Equity'}</h2></div>
{loading && <div className="text-muted" style={{ padding: '24px 0' }}>Chargement…</div>}
{err && <div className="error">{err}</div>}
{!loading && !err && rows.length === 0 && (
<EmptyState
icon="📈"
title="Aucun investissement enregistré"
message="Ajoutez votre premier investissement Private Equity pour voir apparaître les indicateurs et graphiques du portefeuille."
cta="Ajouter un investissement"
to="/investissements?new=1"
/>
)}
{!loading && !err && rows.length > 0 && (
<>
<div className="kpi-grid">
<KpiCard title="Capital investi total" value={fmtEUR(totals.investi)} />
<KpiCard
title="Porte-monnaie"
value={wallet ? fmtEUR(wallet.solde_portefeuille_total) : '…'}
note="Cash disponible sur les plateformes, hors compte courant"
/>
<KpiCard title="Valorisation nette (NAV)" value={fmtEUR(totals.nav)} />
<KpiCard title="Distributions cumulées" value={fmtEUR(totals.distributions)} />
<KpiCard title="TVPI portefeuille" value={fmtMultiple(totals.tvpi)} note="Valeur totale / capital investi" />
<KpiCard title="DPI (réalisé)" value={fmtMultiple(totals.dpi)} note="Distributions / capital investi" />
<KpiCard title="RVPI (non réalisé)" value={fmtMultiple(totals.rvpi)} note="Valorisation résiduelle / capital investi" />
<KpiCard title="TRI (XIRR) global" value={xirrDisplay} color={xirrColor} note={xirrNote} />
<KpiCard title="Frais cumulés" value={fmtEUR(totals.fraisCumules)} />
<KpiCard title="Deals" value={`${totals.actifs} actif${totals.actifs > 1 ? 's' : ''}`} note={`${totals.soldes} soldé${totals.soldes > 1 ? 's' : ''}`} />
<KpiCard
title="Valorisations à rafraîchir"
value={String(totals.staleCount)}
color={totals.staleCount > 0 ? 'var(--warning)' : undefined}
note={staleNote}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '5fr 3fr', gap: 16, marginTop: 8, marginBottom: 16 }}>
<ValorisationPeChart rows={rows} valorisations={valorisations} platYear={null} />
<BarBreakdown title="Capital investi par plateforme" items={capitalParPlateforme} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
<BarBreakdown title="Capital investi par stratégie" items={capitalParStrategie} />
<BarBreakdown title="Frais cumulés par catégorie" items={fraisParCategorie} />
</div>
<div className="card">
<h3 style={{ margin: '0 0 14px' }}>Performance par deal</h3>
<table>
<thead>
<tr>
<th>Deal</th><th>Plateforme</th><th>Statut</th>
<th className="num">Capital investi</th>
<th className="num">Distributions</th>
<th className="num">Valorisation actuelle</th>
<th>TVPI</th>
</tr>
</thead>
<tbody>
{ranking.map(r => (
<tr key={r.id}>
<td>{r.nom_deal}</td>
<td className="text-muted">{r.plateforme_nom}</td>
<td className="text-muted">{STATUT_LABELS[r.statut] || r.statut}</td>
<td className="num">{fmtEUR(r.montant_investi)}</td>
<td className="num">{fmtEUR(r.distributions_cumulees || 0)}</td>
<td className="num">{fmtEUR(r.derniere_valorisation ?? r.montant_investi)}</td>
<td><TvpiBar value={r.tvpi} /></td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</>
);
}
+10 -5
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { usePagination } from '../hooks/usePagination.js';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import Pagination from '../components/Pagination.jsx';
import PageIcon from '../components/PageIcon.jsx';
import EmptyState from '../components/EmptyState.jsx';
@@ -115,6 +116,7 @@ export default function DepotsRetraits() {
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(emptyMouvementForm);
const [err, setErr] = useState(null);
const { run: runSubmit, pending: submitting } = useAsyncAction();
const [loading, setLoading] = useState(false);
const [selectedRow, setSelectedRow] = useState(null);
const [drPlatYear, setDrPlatYear] = useState('');
@@ -1679,20 +1681,23 @@ export default function DepotsRetraits() {
open={modalOpen}
title={editingId ? 'Modifier le mouvement' : 'Nouveau mouvement'}
onClose={close}
busy={submitting}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<div style={{ display: 'flex', gap: 8 }}>
{editingId && <button className="danger" onClick={onDelete}>Supprimer</button>}
{editingId && <button onClick={() => openCorrection(null)}>Correction de solde</button>}
{editingId && <button className="danger" disabled={submitting} onClick={onDelete}>Supprimer</button>}
{editingId && <button disabled={submitting} onClick={() => openCorrection(null)}>Correction de solde</button>}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={close}>Annuler</button>
<button className="primary" onClick={submit}>{editingId ? 'Enregistrer' : 'Créer'}</button>
<button disabled={submitting} onClick={close}>Annuler</button>
<button className="primary" disabled={submitting} onClick={() => runSubmit(submit)}>
{submitting ? (editingId ? 'Enregistrement…' : 'Création…') : (editingId ? 'Enregistrer' : 'Créer')}
</button>
</div>
</div>
}
>
<form onSubmit={submit}>
<form onSubmit={e => runSubmit(submit, e)}>
{err && <div className="error">{err}</div>}
<div className="form-grid">
<div>
+676
View File
@@ -0,0 +1,676 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useWorkspace } from '../context/WorkspaceContext.jsx';
import Modal from '../components/Modal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx';
import EmptyState from '../components/EmptyState.jsx';
import PageIcon from '../components/PageIcon.jsx';
import SoldeChart from '../components/SoldeChart.jsx';
import DistributionChart from '../components/DistributionChart.jsx';
import DepotsMensuelTable from '../components/DepotsMensuelTable.jsx';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import { fmtEUR, fmtDate, rangeFromDate } from '../utils/format.js';
import { emptyMouvementForm } from '../components/MouvementsShared.jsx';
/**
* Dépôts / Retraits — version simplifiée, utilisée par tout workspace autre
* que crowdlending (Private Equity pour l'instant — cf. DepotsRetraitsRouter
* dans App.jsx, jamais lié en dur à un slug précis).
*
* Depuis le 17/09/26 (demande d'Olivier : "adapter la page dépôt/retrait pour
* qu'elle ressemble à la page dépôt/retrait de Crowdlending mais avec les
* données de Private Equity"), cette page reprend le graphique de solde
* cumulé, la Distribution par plateforme, les KPIs (Dépôts/Retraits/Diff/
* Porte-monnaie) et les onglets Plateformes + Vision mensuelle de
* pages/DepotsRetraits.jsx (crowdlending).
*
* Volontairement absents pour cette itération (décisions Olivier du 17/09/26) :
* - L'onglet "Mouvements" façon crowdlending (journal unifié dépôts/retraits +
* corrections + remboursements + souscriptions + frais/prélèvements fiscaux,
* cf. MouvementsShared.jsx) : trop couplé à la fiscalité crowdlending, différé.
* L'onglet "Mouvements" ci-dessous reste la version simple préexistante
* (depots_retraits uniquement), avec les mêmes filtres type/plateforme/année
* que les autres onglets.
* - L'onglet "Objectifs" (table `objectifs`, pas encore rattachée à un
* workspace_id) : hors scope pour l'instant.
* - La Distribution est groupée par Plateforme comme côté Crowdlending (le
* composant DistributionChart groupe en dur sur plateforme_nom, choix
* confirmé par Olivier plutôt qu'un regroupement par deal).
*
* Le porte-monnaie par plateforme et les KPIs viennent du nouveau endpoint
* GET /api/dashboard-pe (backend/src/routes/dashboardPe.js) — équivalent PE de
* GET /api/dashboard, basé sur investissements_pe.montant_investi au lieu de
* investissements, sans corrections/réinvestissements/frais_operations.
*/
export default function DepotsRetraitsSimple() {
const { activeId: investisseurId, investisseurs } = useInvestisseur();
const { active: activeWorkspace } = useWorkspace() || {};
const location = useLocation();
const navigate = useNavigate();
const [rows, setRows] = useState([]);
const [allPlats, setAllPlats] = useState([]);
const [dashData, setDashData] = useState(null);
const [dashDataAsOf, setDashDataAsOf] = useState({});
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(emptyMouvementForm);
const [deleteTarget, setDeleteTarget] = useState(null);
const { run: runSubmit, pending: submitting } = useAsyncAction();
const [activeTab, setActiveTab] = useState('plateformes');
// Défaut 'TOUT' aligné sur DepotsRetraits.jsx (crowdlending) — évite qu'un
// jeu de données encore clairsemé (peu de mouvements, potentiellement plus
// vieux qu'1 an) fasse disparaître silencieusement la Distribution.
const [chartRange, setChartRange] = useState('TOUT');
const [filter, setFilter] = useState({ type: '', plateforme_ids: [] });
const [platYear, setPlatYear] = useState('');
// Plateformes éligibles au workspace actif uniquement (GET /plateformes
// n'est pas filtré côté serveur — seulement enrichi de row.workspaces).
const plats = useMemo(
() => allPlats.filter(p => (p.workspaces || []).some(w => w.id === activeWorkspace?.id)),
[allPlats, activeWorkspace],
);
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
const load = async () => {
setLoading(true);
try {
const [dr, pl, dash] = await Promise.all([
api.get('/depots-retraits'),
api.get('/plateformes'),
api.get('/dashboard-pe'),
]);
setRows(dr);
setAllPlats(pl);
setDashData(dash);
} catch (e) {
setErr(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (investisseurId) load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [investisseurId, activeWorkspace?.id]);
const years = useMemo(
() => [...new Set(rows.map(r => r.date_operation.slice(0, 4)))].sort().reverse(),
[rows],
);
// Snapshots fin d'année (année sélectionnée + année précédente, pour le
// TrendBadge) — chargés à la demande, mêmes principes que DepotsRetraits.jsx.
const prevYear = String(Number(platYear || new Date().getFullYear()) - 1);
useEffect(() => {
if (!platYear) return;
[platYear, prevYear].forEach(y => {
const key = `${y}-12-31`;
if (dashDataAsOf[key]) return;
api.get('/dashboard-pe', { asOf: key })
.then(snap => setDashDataAsOf(prev => ({ ...prev, [key]: snap })))
.catch(() => {});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platYear, investisseurId, activeWorkspace?.id]);
const investisseurForPlat = (platId) => {
const plat = plats.find(p => String(p.id) === String(platId));
return plat?.investisseur_id ? String(plat.investisseur_id) : String(investisseurId || '');
};
const openAdd = () => {
setEditingId(null);
const firstId = plats[0]?.id || '';
setForm({ ...emptyMouvementForm, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) });
setErr(null);
setModalOpen(true);
};
// Ouverture auto depuis le bouton global "Nouveau dépôt/retrait" (topbar/burger,
// cf. Layout.jsx — même ?new=1 que la page crowdlending). Le paramètre est
// retiré de l'URL dès sa lecture, indépendamment du chargement des
// plateformes ; un ref différé ouvre la modale une fois `plats` disponible,
// pour ne jamais perdre la demande si elle arrive avant le premier chargement.
const pendingNew = useRef(false);
useEffect(() => {
const params = new URLSearchParams(location.search);
if (params.get('new') !== '1') return;
navigate('/depots-retraits', { replace: true });
if (plats.length > 0) {
openAdd();
} else {
pendingNew.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.search]);
useEffect(() => {
if (pendingNew.current && plats.length > 0) {
pendingNew.current = false;
openAdd();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [plats.length]);
const openEdit = (r) => {
setEditingId(r.id);
setForm({
investisseur_id: String(r.investisseur_id || ''),
plateforme_id: String(r.plateforme_id || ''),
date_operation: r.date_operation,
type: r.type,
montant: String(r.montant ?? ''),
libelle: r.libelle || '',
reference: r.reference || '',
notes: r.notes || '',
});
setErr(null);
setModalOpen(true);
};
const close = () => { setModalOpen(false); setEditingId(null); setForm(emptyMouvementForm); setErr(null); };
const submit = async (e) => {
e?.preventDefault?.(); setErr(null);
const payload = {
investisseur_id: Number(form.investisseur_id) || undefined,
plateforme_id: Number(form.plateforme_id),
date_operation: form.date_operation,
type: form.type,
montant: Number(form.montant),
libelle: form.libelle || undefined,
reference: form.reference || undefined,
notes: form.notes || undefined,
};
try {
if (editingId) await api.put(`/depots-retraits/${editingId}`, payload);
else await api.post('/depots-retraits', payload);
close();
await load();
} catch (e) { setErr(e.message); }
};
const onDelete = async () => {
if (!deleteTarget) return;
try {
await api.del(`/depots-retraits/${deleteTarget.id}`);
setDeleteTarget(null);
await load();
} catch (e) { setErr(e.message); }
};
/* ── Graphique / Distribution / KPIs (mêmes principes que DepotsRetraits.jsx,
* sans le filtre mois — pas d'onglet Mouvements multi-source ici) ── */
const kpiRows = rows.filter(r => {
if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) return false;
if (platYear && r.date_operation.slice(0, 4) !== platYear) return false;
return true;
});
const chartRows = kpiRows.filter(r => !filter.type || r.type === filter.type);
const distributionRows = useMemo(() => {
const from = rangeFromDate(chartRange);
if (!from) return chartRows;
return chartRows.filter(r => r.date_operation.slice(0, 10) >= from);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chartRows, chartRange]);
const totals = kpiRows.reduce((acc, r) => {
if (r.type === 'depot') acc.depots += r.montant; else acc.retraits += r.montant;
return acc;
}, { depots: 0, retraits: 0 });
const soldeFromSnapshot = (snap) => {
if (!snap) return null;
if (filter.plateforme_ids.length > 0) {
return (snap.cashByPlatform ?? [])
.filter(c => filter.plateforme_ids.includes(String(c.plateforme_id)))
.reduce((s, c) => s + (c.solde_portefeuille ?? 0), 0);
}
return snap.solde_portefeuille_total ?? null;
};
const kpiSoldePortefeuille = (() => {
if (!platYear) {
if (filter.plateforme_ids.length === 1 && dashData) {
const p = dashData.cashByPlatform?.find(c => String(c.plateforme_id) === filter.plateforme_ids[0]);
return p?.solde_portefeuille ?? null;
}
return dashData?.solde_portefeuille_total ?? null;
}
return soldeFromSnapshot(dashDataAsOf[`${platYear}-12-31`]);
})();
const prevKpiSoldePortefeuille = platYear ? soldeFromSnapshot(dashDataAsOf[`${prevYear}-12-31`]) : null;
const prevTotals = useMemo(() => {
if (!platYear) return { depots: 0, retraits: 0 };
return rows.filter(r => {
if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) return false;
return r.date_operation.slice(0, 4) === prevYear;
}).reduce((acc, r) => {
if (r.type === 'depot') acc.depots += r.montant; else acc.retraits += r.montant;
return acc;
}, { depots: 0, retraits: 0 });
}, [rows, filter.plateforme_ids, platYear, prevYear]);
/* ── Données de l'onglet Plateformes (directement depuis dashboard-pe,
* pas de recalcul client contrairement à DepotsRetraits.jsx — le endpoint
* PE supporte déjà ?asOf nativement) ── */
const platData = useMemo(() => {
const src = platYear
? (dashDataAsOf[`${platYear}-12-31`]?.cashByPlatform ?? [])
: (dashData?.cashByPlatform ?? []);
return [...src].sort((a, b) =>
a.nom.localeCompare(b.nom, 'fr') || (a.detenteur_nom ?? '').localeCompare(b.detenteur_nom ?? '', 'fr'));
}, [dashData, dashDataAsOf, platYear]);
const platTotals = useMemo(() => platData.reduce((acc, p) => {
acc.depots += p.depots; acc.retraits += p.retraits;
acc.solde_net += p.solde_net; acc.solde_portefeuille += p.solde_portefeuille ?? 0;
return acc;
}, { depots: 0, retraits: 0, solde_net: 0, solde_portefeuille: 0 }), [platData]);
/* ── Onglet Mouvements : liste simple depots_retraits, avec les mêmes
* filtres type/plateforme/année que les autres onglets ── */
const mouvementsRows = useMemo(
() => [...kpiRows]
.filter(r => !filter.type || r.type === filter.type)
.sort((a, b) => b.date_operation.localeCompare(a.date_operation) || b.id - a.id),
[kpiRows, filter.type],
);
const hasActiveFilter = filter.type || filter.plateforme_ids.length > 0 || platYear;
const clearFilters = () => { setFilter({ type: '', plateforme_ids: [] }); setPlatYear(''); };
if (!loading && plats.length === 0) return (
<>
<div className="topbar"><h2><PageIcon name="depots-retraits" />Dépôts / Retraits</h2></div>
<EmptyState
title="Aucune plateforme dans cet espace de travail"
message={`Ajoutez d'abord une plateforme à ${activeWorkspace?.libelle_menu || activeWorkspace?.nom || 'ce workspace'} depuis Mes plateformes pour pouvoir y enregistrer des dépôts et retraits.`}
cta="Configurer mes plateformes"
to="/settings?section=plateformes&openAdd=1"
/>
</>
);
if (!loading && !modalOpen && rows.length === 0) return (
<>
<div className="topbar"><h2><PageIcon name="depots-retraits" />Dépôts / Retraits</h2></div>
<EmptyState
icon="💶"
title="Aucun dépôt enregistré"
message="Vos plateformes sont configurées, mais aucun dépôt n'a encore été enregistré."
cta="Ajouter un dépôt"
to="/depots-retraits?new=1"
/>
<Modal
open={modalOpen}
title={editingId ? 'Modifier le mouvement' : 'Nouveau dépôt/retrait'}
onClose={close}
busy={submitting}
footer={
<div style={{ display: 'flex', gap: 8 }}>
<button disabled={submitting} onClick={close}>Annuler</button>
<button className="primary" disabled={submitting} onClick={() => runSubmit(submit)}>
{submitting ? 'Création…' : 'Créer'}
</button>
</div>
}
>
<DepotsRetraitsForm form={form} setForm={setForm} plats={plats} investisseurs={investisseurs}
multiDetenteur={multiDetenteur} err={err} runSubmit={runSubmit} submit={submit} investisseurForPlat={investisseurForPlat} />
</Modal>
</>
);
return (
<>
<div className="topbar">
<h2><PageIcon name="depots-retraits" />Dépôts / Retraits</h2>
{hasActiveFilter && (
<button className="dr-clear-btn" onClick={clearFilters}>✕ Effacer les filtres</button>
)}
</div>
{err && <div className="error" style={{ margin: '0 0 16px' }}>{err}</div>}
{/* ── Graphiques ── */}
<div className="charts-row">
<SoldeChart rows={chartRows} range={chartRange} onRangeChange={setChartRange} />
<DistributionChart rows={distributionRows} />
</div>
{/* ── KPIs ── */}
<div className="dr-kpi-row">
<div
className={`kpi dr-kpi-clickable${filter.type === 'depot' ? ' dr-kpi-active dr-kpi-active-success' : ''}`}
onClick={() => setFilter(f => ({ ...f, type: f.type === 'depot' ? '' : 'depot' }))}
title="Filtrer sur les dépôts"
>
<div className="label">Dépôts</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.depots)}</span>
{platYear && <TrendBadge current={totals.depots} prev={prevTotals.depots} />}
</div>
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.depots)} en {prevYear}</div>}
</div>
<div
className={`kpi dr-kpi-clickable${filter.type === 'retrait' ? ' dr-kpi-active dr-kpi-active-danger' : ''}`}
onClick={() => setFilter(f => ({ ...f, type: f.type === 'retrait' ? '' : 'retrait' }))}
title="Filtrer sur les retraits"
>
<div className="label">Retraits</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.retraits)}</span>
{platYear && <TrendBadge current={totals.retraits} prev={prevTotals.retraits} invert={true} />}
</div>
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.retraits)} en {prevYear}</div>}
</div>
<div
className={`kpi${hasActiveFilter ? ' dr-kpi-clickable dr-kpi-active dr-kpi-active-neutral' : ''}`}
onClick={hasActiveFilter ? clearFilters : undefined}
title={hasActiveFilter ? 'Effacer les filtres' : undefined}
style={hasActiveFilter ? { cursor: 'pointer' } : {}}
>
<div className="label">Diff. Dépôts vs Retraits</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.depots - totals.retraits)}</span>
{platYear && <TrendBadge current={totals.depots - totals.retraits} prev={prevTotals.depots - prevTotals.retraits} />}
</div>
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.depots - prevTotals.retraits)} en {prevYear}</div>}
</div>
<div className="kpi" title="Cash disponible sur les porte-monnaie des plateformes">
<div className="label">Porte-monnaie</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{kpiSoldePortefeuille !== null ? fmtEUR(kpiSoldePortefeuille) : '…'}</span>
{platYear && kpiSoldePortefeuille !== null && <TrendBadge current={kpiSoldePortefeuille} prev={prevKpiSoldePortefeuille} />}
</div>
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevKpiSoldePortefeuille)} en {prevYear}</div>}
</div>
</div>
{/* ── Onglets ── */}
<div className="dr-tabs">
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
onClick={() => setActiveTab('plateformes')}>Plateformes</button>
<button className={`dr-tab${activeTab === 'vision-mensuelle' ? ' active' : ''}`}
onClick={() => setActiveTab('vision-mensuelle')}>Vision mensuelle</button>
<button className={`dr-tab${activeTab === 'mouvements' ? ' active' : ''}`}
onClick={() => setActiveTab('mouvements')}>Mouvements</button>
</div>
{/* ====== ONGLET PLATEFORMES ====== */}
{activeTab === 'plateformes' && (
<div style={{ padding: '0 24px' }}>
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>
Répartition par plateforme
{platYear && <span style={{ marginLeft: 8 }}>pour l'année {platYear}</span>}
</h3>
<select value={platYear} onChange={e => setPlatYear(e.target.value)}
style={{
fontSize: 'var(--fs-xs)', padding: '4px 8px', height: 30,
borderRadius: 6, border: '1px solid var(--border)',
background: 'var(--surface-2)', color: 'var(--text-muted)',
cursor: 'pointer', outline: 'none',
}}>
<option value="">Toutes les années</option>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
{!dashData ? (
<div className="text-muted" style={{ padding: '12px 0' }}>Chargement…</div>
) : (
<table>
<thead>
<tr><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}<th className="num">Dépôts</th><th className="num">Retraits</th><th className="num">Diff. Dépôts vs Retraits</th><th className="num">Porte-monnaie</th><th className="num">Poids</th><th style={{ width: 28 }}></th></tr>
</thead>
<tbody>
{platData.length === 0 && (
<tr><td colSpan={multiDetenteur ? 8 : 7} className="text-muted" style={{ textAlign: 'center' }}>Aucune plateforme</td></tr>
)}
{(() => {
const totalSolde = platTotals.solde_net;
return platData.map(p => {
const isActive = filter.plateforme_ids.includes(String(p.plateforme_id));
const poids = totalSolde !== 0 ? (p.solde_net / totalSolde) * 100 : 0;
return (
<tr key={p.plateforme_id}
className={`dr-row${isActive ? ' dr-row-selected' : ''}`}
style={{ cursor: 'pointer' }}
onClick={() => setFilter(f => ({
...f,
plateforme_ids: isActive
? f.plateforme_ids.filter(id => id !== String(p.plateforme_id))
: [...f.plateforme_ids, String(p.plateforme_id)]
}))}>
<td><span style={{ fontWeight: isActive ? 600 : undefined }}>{p.nom}</span></td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>}
<td className="num">{fmtEUR(p.depots)}</td>
<td className="num">{fmtEUR(p.retraits)}</td>
<td className="num">{fmtEUR(p.solde_net)}</td>
<td className="num">{fmtEUR(p.solde_portefeuille ?? 0)}</td>
<td className="num">
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
</div>
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>{poids.toFixed(1)} %</span>
</div>
</td>
<td style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: 11 }}>{isActive ? '✕' : ''}</td>
</tr>
);
});
})()}
</tbody>
{platData.length > 1 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td>Total — {platData.length} plateformes</td>
{multiDetenteur && <td />}
<td className="num">{fmtEUR(platTotals.depots)}</td>
<td className="num">{fmtEUR(platTotals.retraits)}</td>
<td className="num">{fmtEUR(platTotals.solde_net)}</td>
<td className="num">{fmtEUR(platTotals.solde_portefeuille)}</td>
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
<td />
</tr>
</tfoot>
)}
</table>
)}
</div>
</div>
)}
{/* ====== ONGLET VISION MENSUELLE ====== */}
{activeTab === 'vision-mensuelle' && (
<div style={{ padding: '0 24px' }}>
<DepotsMensuelTable allRows={rows} plats={plats} />
</div>
)}
{/* ====== ONGLET MOUVEMENTS ====== */}
{activeTab === 'mouvements' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Mouvements</h3>
<button className="primary" onClick={openAdd}>+ Nouveau dépôt/retrait</button>
</div>
<table>
<thead>
<tr>
<th>Date</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}
<th>Type</th><th className="num">Montant</th><th>Libellé</th><th style={{ width: 40 }}></th>
</tr>
</thead>
<tbody>
{mouvementsRows.length === 0 && (
<tr><td colSpan={multiDetenteur ? 7 : 6} className="text-muted" style={{ textAlign: 'center' }}>Aucun mouvement</td></tr>
)}
{mouvementsRows.map(r => (
<tr key={r.id} className="dr-row" style={{ cursor: 'pointer' }} onClick={() => openEdit(r)}>
<td>{fmtDate(r.date_operation)}</td>
<td>{r.plateforme_nom}</td>
{multiDetenteur && <td className="text-muted">{r.plateforme_detenteur_nom || '—'}</td>}
<td>{r.type === 'depot' ? 'Dépôt' : 'Retrait'}</td>
<td className="num">{fmtEUR(r.montant)}</td>
<td className="text-muted">{r.libelle || '—'}</td>
<td onClick={e => e.stopPropagation()}>
<button className="icon-btn" title="Supprimer" onClick={() => setDeleteTarget(r)}>✕</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<Modal
open={modalOpen}
title={editingId ? 'Modifier le mouvement' : 'Nouveau dépôt/retrait'}
onClose={close}
busy={submitting}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<div>
{editingId && (
<button className="danger" disabled={submitting}
onClick={() => { const target = { id: editingId }; close(); setDeleteTarget(target); }}>
Supprimer
</button>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button disabled={submitting} onClick={close}>Annuler</button>
<button className="primary" disabled={submitting} onClick={() => runSubmit(submit)}>
{submitting ? (editingId ? 'Enregistrement…' : 'Création…') : (editingId ? 'Enregistrer' : 'Créer')}
</button>
</div>
</div>
}
>
<DepotsRetraitsForm form={form} setForm={setForm} plats={plats} investisseurs={investisseurs}
multiDetenteur={multiDetenteur} err={err} runSubmit={runSubmit} submit={submit} investisseurForPlat={investisseurForPlat} />
</Modal>
<ConfirmModal
open={!!deleteTarget}
title="Supprimer ce mouvement ?"
message="Cette action est irréversible."
confirmLabel="Supprimer"
onConfirm={onDelete}
onCancel={() => setDeleteTarget(null)}
/>
</>
);
}
/* ── Indicateur de progression (copié de pages/DepotsRetraits.jsx — pas
* exporté par MouvementsShared.jsx) ── */
function TrendBadge({ current, prev, invert = false }) {
if (prev == null || Math.abs(prev) < 0.005) return null;
const diff = current - prev;
const pct = (diff / prev) * 100;
const up = diff > 0;
const neutral = diff === 0;
const good = neutral ? null : (invert ? !up : up);
const color = neutral ? 'var(--text-muted)' : good ? '#16a34a' : '#dc2626';
const bg = neutral ? 'var(--surface-2)' : good ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)';
const arrow = neutral ? '→' : up ? '↗' : '↘';
const label = `${up ? '+' : ''}${Math.abs(pct) < 10 ? pct.toFixed(1) : Math.round(pct)}%`;
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '2px 8px', borderRadius: 20,
background: bg, color, fontSize: '0.76em', fontWeight: 600,
whiteSpace: 'nowrap',
}}>
{arrow} {label}
</span>
);
}
/** Formulaire d'ajout/édition — factorisé pour être identique dans les deux
* points d'entrée (page avec mouvements existants, et état vide). Champs
* strictement alignés sur le Schema de backend/src/routes/depotsRetraits.js. */
function DepotsRetraitsForm({ form, setForm, plats, investisseurs, multiDetenteur, err, runSubmit, submit, investisseurForPlat }) {
return (
<form onSubmit={e => runSubmit(submit, e)}>
{err && <div className="error">{err}</div>}
<div className="form-grid">
<div>
<label>Plateforme *</label>
<select required value={form.plateforme_id} onChange={e => {
const platId = e.target.value;
setForm(f => ({ ...f, plateforme_id: platId, investisseur_id: investisseurForPlat(platId) }));
}}>
<option value="">—</option>
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
</select>
</div>
<div>
<label>Date *</label>
<input type="date" required value={form.date_operation} onChange={e => setForm({ ...form, date_operation: e.target.value })} />
</div>
<div>
<label>Type *</label>
<select required value={form.type} onChange={e => setForm({ ...form, type: e.target.value })}>
<option value="depot">Dépôt</option>
<option value="retrait">Retrait</option>
</select>
</div>
<div>
<label>Montant (€) *</label>
<input type="number" step="0.01" min="0" required value={form.montant} onChange={e => setForm({ ...form, montant: e.target.value })} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Libellé</label>
<input value={form.libelle} onChange={e => setForm({ ...form, libelle: e.target.value })} placeholder="Ex. Appel de fonds" />
</div>
<div>
<label>Référence</label>
<input value={form.reference} onChange={e => setForm({ ...form, reference: e.target.value })} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Détenteur *</label>
<select required value={form.investisseur_id} onChange={e => setForm({ ...form, investisseur_id: e.target.value })}>
<option value="">—</option>
{investisseurs.filter(i => i.type !== 'entreprise').length > 0 && (
<optgroup label="Famille">
{investisseurs.filter(i => i.type !== 'entreprise').map(i => (
<option key={i.id} value={i.id}>{i.nom}{i.is_principal ? ' (principal)' : ''}</option>
))}
</optgroup>
)}
{investisseurs.filter(i => i.type === 'entreprise').length > 0 && (
<optgroup label="Entreprises">
{investisseurs.filter(i => i.type === 'entreprise').map(i => (
<option key={i.id} value={i.id}>{i.nom}</option>
))}
</optgroup>
)}
</select>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Notes</label>
<input value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />
</div>
</div>
</form>
);
}
+5 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { usePagination } from '../hooks/usePagination.js';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import Pagination from '../components/Pagination.jsx';
import PageIcon from '../components/PageIcon.jsx';
import EmptyState from '../components/EmptyState.jsx';
@@ -198,6 +199,7 @@ export default function Frais() {
};
const closeModal = () => { setModalOpen(false); setErr(null); setConfirmingDelete(false); };
const { run: runSubmit, pending: submitting } = useAsyncAction();
// Callback générique (onFieldChange) — mêmes effets de bord que le formulaire d'origine
// (InvestissementDetail.jsx) : changer mode_reglement réinitialise compte_id sauf en passant
@@ -540,8 +542,9 @@ export default function Frais() {
form={form}
onFieldChange={setField}
err={err}
onSubmit={submit}
onDelete={onDelete}
onSubmit={e => runSubmit(submit, e)}
onDelete={() => runSubmit(onDelete)}
submitting={submitting}
comptesInvestisseur={comptesInvestisseur}
investissementOptions={investissementOptions}
confirmingDelete={confirmingDelete}
+565
View File
@@ -0,0 +1,565 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useWorkspace } from '../context/WorkspaceContext.jsx';
import { usePagination } from '../hooks/usePagination.js';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import Pagination from '../components/Pagination.jsx';
import PageIcon from '../components/PageIcon.jsx';
import EmptyState from '../components/EmptyState.jsx';
import FraisFormModal from '../components/FraisFormModal.jsx';
import FraisCumulChart from '../components/FraisCumulChart.jsx';
import FraisDistributionChart from '../components/FraisDistributionChart.jsx';
import { FRAIS_CATEGORIE_LABELS } from '../components/MouvementsShared.jsx';
import { fmtEUR, fmtDate, today } from '../utils/format.js';
/**
* Page dédiée "Frais" pour le workspace Private Equity (chantier "frais PE", 17/09/26 —
* demande Olivier : "il faut une page sur les frais"). Adaptation à l'identique de Frais.jsx
* (crowdlending) — même structure (graphiques, KPI, onglets Plateformes / Typologie /
* Investissements / Opérations), parité complète demandée explicitement par Olivier — avec
* les différences suivantes :
* - source des données : /investissements-pe (deals) au lieu de /investissements, et
* GET /frais-operations sans filtre se borne déjà au workspace ACTIF côté backend
* (resolveActiveWorkspaceId), donc renvoie naturellement les frais PE ici ;
* - libellé "deal" / nom_deal au lieu de "projet" / nom_projet ;
* - gating d'accès via hasFraisPe (cf. AuthContext.jsx) plutôt que hasFrais, cf. FraisPeOnly
* dans App.jsx.
*
* Le composant FraisFormModal (partagé, inchangé) attend en interne un champ de formulaire
* nommé `investissement_id` pour son sélecteur "Investissement" — ce nom est conservé tel
* quel dans l'état local de cette page (setField / emptyForm ci-dessous) et traduit en
* `investissement_pe_id` uniquement au moment de construire le payload envoyé à l'API
* (submit ci-dessous), pour ne rien modifier au composant partagé avec crowdlending.
*/
const FRAIS_MODE_LABELS = {
source: 'À la source',
portefeuille: 'Porte-monnaie',
compte_courant: 'Compte courant',
remboursement: 'Retenu sur remboursement',
};
function emptyForm() {
return { investissement_id: '', montant: '', mode_reglement: 'source', compte_id: '', date_operation: today(), notes: '', categorie: 'autre' };
}
export default function FraisPe() {
const { activeId, activeView } = useInvestisseur();
const { active: activeWorkspace } = useWorkspace() || {};
const [allFraisOps, setAllFraisOps] = useState([]);
const [deals, setDeals] = useState([]);
const [allPlats, setAllPlats] = useState([]);
const [platLoaded, setPlatLoaded] = useState(false);
const [loading, setLoading] = useState(false);
/* Onglets (parité avec Frais.jsx) */
const [activeTab, setActiveTab] = useState('plateformes');
/* Modale ajout/édition */
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(emptyForm());
const [err, setErr] = useState(null);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
// Plateformes éligibles au workspace actif uniquement (GET /plateformes n'est pas filtré
// côté serveur — seulement enrichi de row.workspaces), même patron que InvestissementsPe.jsx.
const plateformes = useMemo(
() => allPlats.filter(p => (p.workspaces || []).some(w => w.id === activeWorkspace?.id)),
[allPlats, activeWorkspace],
);
const multiDetenteur = new Set(plateformes.map(p => p.investisseur_id)).size > 1;
const load = async () => {
if (!activeId && activeView !== 'all') return;
setLoading(true);
try {
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
const [f, d] = await Promise.all([
api.get('/frais-operations'),
api.get('/investissements-pe', scopeParams),
]);
setAllFraisOps(f);
setDeals(d);
} finally {
setLoading(false);
}
};
useEffect(() => {
api.get('/plateformes').then(d => { setAllPlats(d); setPlatLoaded(true); }).catch(() => setPlatLoaded(true));
}, []);
useEffect(() => { load(); /* eslint-disable-next-line */ }, [activeId, activeView, activeWorkspace?.id]);
useEffect(() => {
const deal = deals.find(d => String(d.id) === String(form.investissement_id));
if (!modalOpen || !deal?.investisseur_id) { setComptesInvestisseur([]); return; }
api.get(`/investissements/comptes-par-investisseur/${deal.investisseur_id}`)
.then(setComptesInvestisseur)
.catch(() => setComptesInvestisseur([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modalOpen, form.investissement_id]);
const fraisFiltered = allFraisOps;
const fraisTotal = useMemo(() => fraisFiltered.reduce((s, f) => s + (f.montant || 0), 0), [fraisFiltered]);
const fraisMoyen = fraisFiltered.length ? fraisTotal / fraisFiltered.length : 0;
const fraisSouscriptionTotal = useMemo(() =>
fraisFiltered.filter(f => f.categorie === 'souscription').reduce((s, f) => s + (f.montant || 0), 0),
[fraisFiltered]
);
const fraisDistributionTotal = useMemo(() =>
fraisFiltered.filter(f => f.categorie === 'distribution').reduce((s, f) => s + (f.montant || 0), 0),
[fraisFiltered]
);
/* ── Répartition par catégorie (onglet "Typologie") ── */
const byCategorie = useMemo(() => {
const map = {};
for (const f of fraisFiltered) {
const key = f.categorie || 'autre';
if (!map[key]) map[key] = { key, label: FRAIS_CATEGORIE_LABELS[key] || key, montant: 0, count: 0 };
map[key].montant += f.montant || 0;
map[key].count += 1;
}
return Object.values(map).sort((a, b) => b.montant - a.montant);
}, [fraisFiltered]);
/* ── Répartition par plateforme (onglet "Plateformes") ── */
const byPlateforme = useMemo(() => {
const map = {};
for (const f of fraisFiltered) {
const key = f.plateforme_id;
if (!map[key]) map[key] = { key, label: f.plateforme_nom || '—', detenteur: f.investisseur_nom || '—', montant: 0, count: 0 };
map[key].montant += f.montant || 0;
map[key].count += 1;
}
return Object.values(map).sort((a, b) => b.montant - a.montant);
}, [fraisFiltered]);
/* ── Frais par deal (onglet "Investissements") — uniquement les deals concernés par au
moins un frais ── */
const byInvestissement = useMemo(() => {
const map = {};
for (const f of fraisFiltered) {
const key = f.investissement_pe_id;
if (!map[key]) map[key] = {
key,
plateforme: f.plateforme_nom || '—',
detenteur: f.investisseur_nom || '—',
projet: f.nom_deal || '—',
souscription: 0,
distribution: 0,
total: 0,
};
const cat = f.categorie || 'autre';
if (cat === 'souscription') map[key].souscription += f.montant || 0;
if (cat === 'distribution') map[key].distribution += f.montant || 0;
map[key].total += f.montant || 0;
}
return Object.values(map).sort((a, b) => {
const pa = a.plateforme.toLowerCase(), pb = b.plateforme.toLowerCase();
if (pa !== pb) return pa.localeCompare(pb, 'fr');
return a.projet.localeCompare(b.projet, 'fr');
});
}, [fraisFiltered]);
/* ── Tableau détaillé paginé (onglet "Opérations") ── */
const {
pagedItems: pagedFrais, page, setPage, pageSize, setPageSize,
totalPages, totalItems, PAGE_SIZES,
} = usePagination(fraisFiltered, 'pe_pagesize_frais', []);
/* ── Deals pour le sélecteur de la modale (triés plateforme puis deal) — mappés sur les
mêmes clés que FraisFormModal attend (plateforme_nom / nom_projet), cf. doc en tête. ── */
const investissementOptions = useMemo(() =>
deals.slice()
.sort((a, b) => {
const pa = (a.plateforme_nom || '').toLowerCase();
const pb = (b.plateforme_nom || '').toLowerCase();
if (pa !== pb) return pa.localeCompare(pb, 'fr');
return (a.nom_deal || '').localeCompare(b.nom_deal || '', 'fr');
})
.map(d => ({ id: d.id, plateforme_nom: d.plateforme_nom, nom_projet: d.nom_deal })),
[deals]
);
/* ── CRUD ── */
const openAdd = () => {
setEditingId(null);
setForm(emptyForm());
setErr(null);
setConfirmingDelete(false);
setModalOpen(true);
};
const openEdit = (f) => {
setEditingId(f.id);
setForm({
investissement_id: String(f.investissement_pe_id),
montant: f.montant,
mode_reglement: f.mode_reglement,
compte_id: f.compte_id || '',
date_operation: f.date_operation,
notes: f.notes || '',
categorie: f.categorie || 'autre',
});
setErr(null);
setConfirmingDelete(false);
setModalOpen(true);
};
const closeModal = () => { setModalOpen(false); setErr(null); setConfirmingDelete(false); };
const { run: runSubmit, pending: submitting } = useAsyncAction();
const setField = (key, value) => {
setForm(f => {
if (key === 'mode_reglement') return { ...f, mode_reglement: value, compte_id: value === 'compte_courant' ? f.compte_id : '' };
if (key === 'investissement_id') return { ...f, investissement_id: value, compte_id: '' };
return { ...f, [key]: value };
});
};
const submit = async (e) => {
e?.preventDefault?.();
setErr(null);
try {
const payload = {
investissement_pe_id: Number(form.investissement_id),
montant: Number(form.montant),
mode_reglement: form.mode_reglement,
compte_id: form.mode_reglement === 'compte_courant' && form.compte_id ? Number(form.compte_id) : null,
date_operation: form.date_operation,
origine: 'manuel',
categorie: form.categorie || 'autre',
notes: form.notes || undefined,
};
if (editingId) await api.put(`/frais-operations/${editingId}`, payload);
else await api.post('/frais-operations', payload);
closeModal();
await load();
} catch (e) { setErr(e.message); }
};
const onDelete = async () => {
setErr(null);
try {
await api.del(`/frais-operations/${editingId}`);
closeModal();
await load();
} catch (e) { setErr(e.message); }
};
/* ── Rendu ── */
if (platLoaded && !loading && plateformes.length === 0) return (
<>
<div className="topbar"><h2>Frais</h2></div>
<EmptyState />
</>
);
return (
<>
<div className="topbar">
<h2><PageIcon name="frais" />Frais</h2>
<button className="primary" onClick={openAdd}>+ Ajouter un frais</button>
</div>
{/* ── Graphiques : courbe du cumul des frais + répartition par plateforme ── */}
<div className="charts-row">
<FraisCumulChart rows={fraisFiltered} />
<FraisDistributionChart rows={fraisFiltered} groupBy={f => f.plateforme_nom || '—'} label="par Plateforme" />
</div>
{/* ── KPIs ── */}
<div className="dr-kpi-row">
<div className="kpi">
<div className="label">Frais payés</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisTotal)}</span>
</div>
</div>
<div className="kpi">
<div className="label">Frais de souscription</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisSouscriptionTotal)}</span>
</div>
</div>
<div className="kpi">
<div className="label">Frais de distribution</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisDistributionTotal)}</span>
</div>
</div>
<div className="kpi">
<div className="label">Nombre d'opérations</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fraisFiltered.length}</span>
</div>
</div>
<div className="kpi">
<div className="label">Frais moyen / opération</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisMoyen)}</span>
</div>
</div>
</div>
{/* ── Onglets ── */}
<div className="dr-tabs">
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
onClick={() => setActiveTab('plateformes')}>Plateformes</button>
<button className={`dr-tab${activeTab === 'typologie' ? ' active' : ''}`}
onClick={() => setActiveTab('typologie')}>Typologie</button>
<button className={`dr-tab${activeTab === 'investissements' ? ' active' : ''}`}
onClick={() => setActiveTab('investissements')}>Investissements</button>
<button className={`dr-tab${activeTab === 'operations' ? ' active' : ''}`}
onClick={() => setActiveTab('operations')}>Opérations</button>
</div>
{/* ══ ONGLET PLATEFORMES ══ */}
{activeTab === 'plateformes' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Répartition par plateforme</h3>
</div>
<table>
<thead>
<tr>
<th>Plateforme</th>
{multiDetenteur && <th>Détenteur</th>}
<th className="num">Montant</th>
<th className="num">Nombre</th>
<th className="num" style={{ minWidth: 120 }}>Poids</th>
</tr>
</thead>
<tbody>
{byPlateforme.length === 0 && (
<tr><td colSpan={multiDetenteur ? 5 : 4} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
</td></tr>
)}
{byPlateforme.map(p => {
const poids = fraisTotal !== 0 ? (p.montant / fraisTotal) * 100 : 0;
return (
<tr key={p.key}>
<td>{p.label}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur}</td>}
<td className="num">{fmtEUR(p.montant)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{p.count}</td>
<td className="num">
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
</div>
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>
{poids.toFixed(1)} %
</span>
</div>
</td>
</tr>
);
})}
</tbody>
{byPlateforme.length > 1 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td colSpan={multiDetenteur ? 2 : 1}>Total — {byPlateforme.length} plateformes</td>
<td className="num">{fmtEUR(fraisTotal)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{fraisFiltered.length}</td>
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
)}
{/* ══ ONGLET TYPOLOGIE ══ */}
{activeTab === 'typologie' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Répartition par catégorie</h3>
</div>
<table>
<thead>
<tr>
<th>Catégorie</th>
<th className="num">Montant</th>
<th className="num">Nombre</th>
<th className="num" style={{ minWidth: 120 }}>Poids</th>
</tr>
</thead>
<tbody>
{byCategorie.length === 0 && (
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
</td></tr>
)}
{byCategorie.map(c => {
const poids = fraisTotal !== 0 ? (c.montant / fraisTotal) * 100 : 0;
return (
<tr key={c.key}>
<td>{c.label}</td>
<td className="num">{fmtEUR(c.montant)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{c.count}</td>
<td className="num">
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
</div>
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>
{poids.toFixed(1)} %
</span>
</div>
</td>
</tr>
);
})}
</tbody>
{byCategorie.length > 1 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td>Total — {byCategorie.length} catégories</td>
<td className="num">{fmtEUR(fraisTotal)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{fraisFiltered.length}</td>
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
)}
{/* ══ ONGLET INVESTISSEMENTS — liste des frais par deal ══ */}
{activeTab === 'investissements' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Frais par investissement</h3>
</div>
<table>
<thead>
<tr>
<th>Plateforme</th>
{multiDetenteur && <th>Détenteur</th>}
<th>Deal</th>
<th className="num">Souscription</th>
<th className="num">Distribution</th>
<th className="num">Total Frais</th>
</tr>
</thead>
<tbody>
{byInvestissement.length === 0 && (
<tr><td colSpan={multiDetenteur ? 6 : 5} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
</td></tr>
)}
{byInvestissement.map(d => (
<tr key={d.key}>
<td>{d.plateforme}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{d.detenteur}</td>}
<td>{d.projet}</td>
<td className="num">{fmtEUR(d.souscription)}</td>
<td className="num">{fmtEUR(d.distribution)}</td>
<td className="num" style={{ fontWeight: 600 }}>{fmtEUR(d.total)}</td>
</tr>
))}
</tbody>
{byInvestissement.length > 1 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td colSpan={multiDetenteur ? 3 : 2}>Total — {byInvestissement.length} investissements</td>
<td className="num">{fmtEUR(byInvestissement.reduce((s, d) => s + d.souscription, 0))}</td>
<td className="num">{fmtEUR(byInvestissement.reduce((s, d) => s + d.distribution, 0))}</td>
<td className="num">{fmtEUR(byInvestissement.reduce((s, d) => s + d.total, 0))}</td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
)}
{/* ══ ONGLET OPÉRATIONS — liste détaillée (CRUD) ══ */}
{activeTab === 'operations' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Détail des opérations</h3>
</div>
<table>
<thead>
<tr>
<th>Date</th><th>Plateforme</th><th>Deal</th><th className="num">Montant</th>
<th>Catégorie</th><th>Prélèvement</th><th>Compte</th><th>Notes</th>
<th style={{ width: 36 }}></th>
</tr>
</thead>
<tbody>
{pagedFrais.length === 0 && (
<tr><td colSpan={9} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
</td></tr>
)}
{pagedFrais.map(f => (
<tr key={f.id} style={{ cursor: 'pointer' }} title="Modifier ce frais" onClick={() => openEdit(f)}>
<td>{fmtDate(f.date_operation)}</td>
<td>{f.plateforme_nom}</td>
<td>{f.nom_deal}</td>
<td className="num">{fmtEUR(f.montant)}</td>
<td>{FRAIS_CATEGORIE_LABELS[f.categorie] || f.categorie}</td>
<td style={{ fontSize: 'var(--fs-sm)' }}>{FRAIS_MODE_LABELS[f.mode_reglement] || f.mode_reglement}</td>
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{f.compte_nom || '—'}</td>
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{f.notes || '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 6px', borderRadius: 4, color: 'var(--text-muted)', lineHeight: 1, fontSize: 16 }}
title="Modifier"
onClick={() => openEdit(f)}>⋮</button>
</td>
</tr>
))}
</tbody>
</table>
<Pagination
page={page} setPage={setPage}
pageSize={pageSize} setPageSize={setPageSize}
totalPages={totalPages} totalItems={totalItems}
PAGE_SIZES={PAGE_SIZES}
/>
</div>
</div>
)}
<FraisFormModal
open={modalOpen}
onClose={closeModal}
editingId={editingId}
form={form}
onFieldChange={setField}
err={err}
onSubmit={e => runSubmit(submit, e)}
onDelete={() => runSubmit(onDelete)}
submitting={submitting}
comptesInvestisseur={comptesInvestisseur}
investissementOptions={investissementOptions}
confirmingDelete={confirmingDelete}
setConfirmingDelete={setConfirmingDelete}
/>
</>
);
}
+33 -16
View File
@@ -13,6 +13,7 @@ import InvestissementFormModal from '../components/InvestissementFormModal.jsx';
import { dayOfMonth } from '../utils/dateEcheances.js';
import { fmtEUR, fmtPct, fmtDate, today, fmtOctets } from '../utils/format.js';
import { xirr } from '../utils/xirr.js';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
const emptyForm = {
investisseur_id: '', plateforme_id: '', nom_projet: '', emetteur: '',
@@ -110,6 +111,12 @@ export default function InvestissementDetail() {
const [modalOpen, setModalOpen] = useState(false);
const [form, setForm] = useState(emptyForm);
const [err, setErr] = useState(null);
// Gardes anti-double-soumission (cf. useAsyncAction) — une par modale indépendante, pour ne
// désactiver que les boutons de la modale concernée pendant sa propre requête.
const { run: runSubmit, pending: submitting } = useAsyncAction();
const { run: runSubmitRemb, pending: submittingRemb } = useAsyncAction();
const { run: runSubmitFrais, pending: submittingFrais } = useAsyncAction();
const { run: runSubmitReinv, pending: submittingReinv } = useAsyncAction();
// Mémorise la dernière souscription valide (utile pour isAutoCalc malgré les onChange "" intermédiaires)
const lastValidSouscriptionRef = useRef('');
@@ -2534,9 +2541,10 @@ export default function InvestissementDetail() {
title={revisionEditId ? 'Corriger la révision' : 'Réviser les conditions du prêt'}
onClose={closeRevisionModal}
width={520}
busy={revisionSaving}
footer={
<>
<button className="ghost" onClick={closeRevisionModal}>Annuler</button>
<button className="ghost" disabled={revisionSaving} onClick={closeRevisionModal}>Annuler</button>
<button onClick={submitRevision} disabled={revisionSaving}>
{revisionSaving ? 'Enregistrement…' : (revisionEditId ? 'Enregistrer la correction' : 'Enregistrer la révision')}
</button>
@@ -2634,8 +2642,9 @@ export default function InvestissementDetail() {
open={modalOpen}
editingId={id}
onClose={close}
onSubmit={submit}
onDelete={onDelete}
onSubmit={e => runSubmit(submit, e)}
onDelete={() => runSubmit(onDelete)}
submitting={submitting}
confirmingDelete={confirmingInvDelete}
setConfirmingDelete={setConfirmingInvDelete}
form={form}
@@ -2717,8 +2726,9 @@ export default function InvestissementDetail() {
open={rembModalOpen}
editingId={editingRembId}
onClose={closeRembModal}
onSubmit={submitRemb}
onDelete={deleteRemb}
onSubmit={e => runSubmitRemb(submitRemb, e)}
onDelete={() => runSubmitRemb(deleteRemb)}
submitting={submittingRemb}
confirmingDelete={confirmingRembDelete}
setConfirmingDelete={setConfirmingRembDelete}
err={rembErr}
@@ -2738,27 +2748,30 @@ export default function InvestissementDetail() {
title="Ajouter un réinvestissement"
onClose={closeReinvModal}
width={480}
busy={submittingReinv}
footer={
reinvTab === 'manuel' ? (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={closeReinvModal}>Annuler</button>
<button className="primary" onClick={submitReinv}>Enregistrer</button>
<button disabled={submittingReinv} onClick={closeReinvModal}>Annuler</button>
<button className="primary" disabled={submittingReinv} onClick={() => runSubmitReinv(submitReinv)}>
{submittingReinv ? 'Enregistrement…' : 'Enregistrer'}
</button>
</div>
) : (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={closeReinvModal}>Annuler</button>
<button disabled={submittingReinv} onClick={closeReinvModal}>Annuler</button>
{!autoReinvActive ? (
<button className="primary" onClick={async () => {
<button className="primary" disabled={submittingReinv} onClick={() => runSubmitReinv(async () => {
await api.put(`/investissements/${id}/auto-reinvest`, { active: true });
await load();
closeReinvModal();
}}>Activer</button>
})}>{submittingReinv ? '…' : 'Activer'}</button>
) : (
<button className="danger" onClick={async () => {
<button className="danger" disabled={submittingReinv} onClick={() => runSubmitReinv(async () => {
await api.put(`/investissements/${id}/auto-reinvest`, { active: false });
await load();
closeReinvModal();
}}>Désactiver</button>
})}>{submittingReinv ? '…' : 'Désactiver'}</button>
)}
</div>
)
@@ -2781,7 +2794,7 @@ export default function InvestissementDetail() {
</div>
{reinvTab === 'manuel' ? (
<form onSubmit={submitReinv}>
<form onSubmit={e => runSubmitReinv(submitReinv, e)}>
<p className="text-muted" style={{ marginTop: 0, marginBottom: 16, fontSize: 13 }}>
Un réinvestissement ajoute du capital à ce prêt à une date donnée. La projection
des intérêts sera recalculée à partir de cette date.
@@ -2854,9 +2867,10 @@ export default function InvestissementDetail() {
title="Modifier la date de la 1ère échéance"
onClose={() => setEditDpeModal(false)}
width={420}
busy={editDpeSaving}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => setEditDpeModal(false)}>Annuler</button>
<button disabled={editDpeSaving} onClick={() => setEditDpeModal(false)}>Annuler</button>
<button className="primary" onClick={saveEditDpe} disabled={editDpeSaving}>
{editDpeSaving ? 'Enregistrement…' : 'Enregistrer'}
</button>
@@ -2886,6 +2900,7 @@ export default function InvestissementDetail() {
title="Traitement en masse des remboursements"
onClose={() => { if (!bulkRembProcessing) setBulkRembModal(false); }}
width={560}
busy={bulkRembProcessing}
footer={(() => {
const selectedCount = bulkRembItems.filter(it => it.selected).length;
return bulkRembDone ? (
@@ -3279,7 +3294,8 @@ export default function InvestissementDetail() {
form={fraisForm}
onFieldChange={setFraisField}
err={fraisErr}
onSubmit={submitFrais}
onSubmit={e => runSubmitFrais(submitFrais, e)}
submitting={submittingFrais}
comptesInvestisseur={comptesFraisInvestisseur}
locked
/>
@@ -3297,9 +3313,10 @@ export default function InvestissementDetail() {
title="Déclarer une perte définitive"
onClose={closePerteModal}
width={520}
busy={perteSaving}
footer={
<>
<button className="ghost" onClick={closePerteModal}>Annuler</button>
<button className="ghost" disabled={perteSaving} onClick={closePerteModal}>Annuler</button>
<button className="danger" onClick={submitPerte} disabled={perteSaving}>
{perteSaving ? 'Enregistrement…' : 'Déclarer la perte'}
</button>
@@ -0,0 +1,624 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useWorkspace } from '../context/WorkspaceContext.jsx';
import Modal from '../components/Modal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx';
import PageIcon from '../components/PageIcon.jsx';
import InvestissementPeFormModal from '../components/InvestissementPeFormModal.jsx';
import FraisFormModal from '../components/FraisFormModal.jsx';
import { FRAIS_CATEGORIE_LABELS } from '../components/MouvementsShared.jsx';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import { fmtEUR, fmtDate, fmtOctets, today } from '../utils/format.js';
import { emptyDealForm, AUTO_VAL_NOTE } from './InvestissementsPe.jsx';
/**
* Fiche détaillée d'un deal Private Equity (chantier "fiche détail PE", 17/09/26 — demande
* Olivier : "une page investissement détail adapté qui reprend un peu l'esprit de crowdlending
* ... il faut que je vois tous les remboursements, tous les frais associés et que j'aie la
* possibilité de stocker des documents"). Première passe volontairement sobre (structure posée,
* à enrichir ensuite) — pas encore de reprise des notions Déploiement / TVPI du sous-jacent /
* Obligation / Valeur totale cible visibles sur les captures Fundora d'Olivier, toujours hors
* périmètre comme convenu lors du chantier précédent.
*
* Route : /investissements/:id, branchée ici pour tout workspace non-crowdlending par
* InvestissementDetailRouter (App.jsx) — miroir de InvestissementDetail.jsx (crowdlending), en
* beaucoup plus court : pas d'échéancier, de XIRR, de révisions de prêt ni de réinvestissements
* (notions sans équivalent PE à ce stade).
*
* Modifier l'investissement réutilise directement InvestissementPeFormModal (le même assistant
* par étapes que la liste), y compris son étape "Frais" (souscription/gestion réglés à la
* source) — le bloc "Frais d'opération" ci-dessous gère lui les frais complémentaires
* (gestion/distribution/autre), exactement comme côté crowdlending : les lignes 'souscription'
* et 'gestion'-à-l'origine-souscription y apparaissent en lecture seule, redirigeant vers
* "Modifier l'investissement" plutôt que vers la modale FraisFormModal (qui n'propose pas la
* catégorie 'souscription' dans son sélecteur).
*/
const fmtMultiple = (n) => (n != null ? `x${n.toFixed(2).replace('.', ',')}` : '—');
const STATUT_LABELS = {
en_attente: 'En attente',
valide: 'Validé',
cloture: 'Clôturé',
perte_definitive: 'Perte définitive',
};
const FRAIS_MODE_LABELS = {
source: 'À la source',
portefeuille: 'Porte-monnaie',
compte_courant: 'Compte courant',
remboursement: 'Retenu sur remboursement',
};
const emptyValForm = { date_valorisation: today(), valorisation_nette: '', notes: '' };
const emptyFraisForm = { montant: '', mode_reglement: 'source', compte_id: '', date_operation: today(), categorie: 'gestion', notes: '' };
export default function InvestissementPeDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { investisseurs } = useInvestisseur();
const { active: activeWorkspace } = useWorkspace() || {};
const [deal, setDeal] = useState(null);
const [allPlats, setAllPlats] = useState([]);
const [remboursements, setRemboursements] = useState([]);
const [fraisOps, setFraisOps] = useState([]);
const [valorisations, setValorisations] = useState([]);
const [documents, setDocuments] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const plats = allPlats.filter(p => (p.workspaces || []).some(w => w.id === activeWorkspace?.id));
const load = async () => {
setLoading(true);
try {
const [d, pl, remb, frais, val, docs] = await Promise.all([
api.get(`/investissements-pe/${id}`),
api.get('/plateformes'),
api.get('/remboursements'),
api.get('/frais-operations', { investissement_pe_id: id }),
api.get(`/investissements-pe/${id}/valorisations`),
api.get('/documents', { entity_type: 'investissement_pe', entity_id: id }),
]);
setDeal(d);
setAllPlats(pl);
setRemboursements(remb.filter(r => String(r.investissement_pe_id) === String(id)));
setFraisOps(frais);
setValorisations(val);
setDocuments(docs);
} catch (e) {
setErr(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => { load(); /* eslint-disable-next-line */ }, [id]);
/* ── Modifier l'investissement (réutilise le même assistant que la liste) ── */
const [modalOpen, setModalOpen] = useState(false);
const [form, setForm] = useState(emptyDealForm);
const [formErr, setFormErr] = useState(null);
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
const { run: runSubmit, pending: submitting } = useAsyncAction();
const [deleteConfirm, setDeleteConfirm] = useState(false);
useEffect(() => {
if (!form.investisseur_id || !modalOpen) { setComptesInvestisseur([]); return; }
api.get(`/investissements/comptes-par-investisseur/${form.investisseur_id}`)
.then(setComptesInvestisseur)
.catch(() => setComptesInvestisseur([]));
}, [form.investisseur_id, modalOpen]);
const openEditDeal = () => {
if (!deal) return;
const entree = fraisOps.find(f => f.categorie === 'souscription');
const gestion = fraisOps.find(f => f.categorie === 'gestion' && f.origine === 'souscription');
const autoVal = valorisations.find(v => v.notes === AUTO_VAL_NOTE);
setForm({
...emptyDealForm,
plateforme_id: String(deal.plateforme_id || ''),
investisseur_id: String(deal.investisseur_id || ''),
nom_deal: deal.nom_deal,
strategie: deal.strategie || '',
date_souscription: deal.date_souscription,
duree_annees: deal.duree_mois ? String(Math.round((deal.duree_mois / 12) * 10) / 10) : '',
montant_investi: String(deal.montant_investi ?? ''),
objectif: deal.objectif || '',
statut: deal.statut,
reference: deal.reference || '',
notes: deal.notes || '',
valorisation_initiale_id: autoVal ? autoVal.id : null,
...(entree ? {
frais_entree_valeur: String(entree.montant), frais_entree_mode_reglement: entree.mode_reglement,
frais_entree_compte_id: entree.compte_id ? String(entree.compte_id) : '', frais_entree_id: entree.id,
} : {}),
...(gestion ? {
frais_gestion_valeur: String(gestion.montant), frais_gestion_mode_reglement: gestion.mode_reglement,
frais_gestion_compte_id: gestion.compte_id ? String(gestion.compte_id) : '', frais_gestion_id: gestion.id,
} : {}),
});
setFormErr(null);
setModalOpen(true);
};
const closeEditDeal = () => { setModalOpen(false); setFormErr(null); };
const investisseurForPlat = (platId) => {
const plat = plats.find(p => String(p.id) === String(platId));
return plat?.investisseur_id ? String(plat.investisseur_id) : String(deal?.investisseur_id || '');
};
const computeFraisTTC = (modeSaisie, valeur) => {
if (valeur === '' || Number(valeur) <= 0) return 0;
return Math.round((modeSaisie === 'pourcentage'
? Number(form.montant_investi) * Number(valeur) / 100
: Number(valeur)) * 100) / 100;
};
const upsertFrais = async (has, ttc, categorie, modeReglement, compteId, fraisId) => {
try {
if (has) {
const payload = {
investissement_pe_id: Number(id),
montant: ttc,
mode_reglement: modeReglement,
compte_id: modeReglement === 'compte_courant' && compteId ? Number(compteId) : null,
date_operation: form.date_souscription,
origine: 'souscription',
categorie,
};
if (fraisId) await api.put(`/frais-operations/${fraisId}`, payload);
else await api.post('/frais-operations', payload);
} else if (fraisId) {
await api.del(`/frais-operations/${fraisId}`);
}
} catch { /* non bloquant */ }
};
const syncValorisationInitiale = async (totalFraisSource, valorisationInitialeId) => {
try {
if (valorisationInitialeId) await api.del(`/investissements-pe/${id}/valorisations/${valorisationInitialeId}`);
if (totalFraisSource > 0) {
const initiale = Math.max(0, Math.round((Number(form.montant_investi) - totalFraisSource) * 100) / 100);
await api.post(`/investissements-pe/${id}/valorisations`, {
date_valorisation: form.date_souscription, valorisation_nette: initiale, notes: AUTO_VAL_NOTE,
});
}
} catch { /* non bloquant */ }
};
const submitDeal = async (e) => {
e?.preventDefault?.(); setFormErr(null);
const platFraisType = plats.find(p => String(p.id) === String(form.plateforme_id))?.frais_plateforme;
const fraisApplicable = !!platFraisType && platFraisType !== 'aucun';
const hasFraisEntree = fraisApplicable && form.frais_entree_valeur !== '' && Number(form.frais_entree_valeur) > 0;
const hasFraisGestion = fraisApplicable && form.frais_gestion_valeur !== '' && Number(form.frais_gestion_valeur) > 0;
const fraisEntreeTTC = hasFraisEntree ? computeFraisTTC(form.frais_entree_mode_saisie, form.frais_entree_valeur) : 0;
const fraisGestionTTC = hasFraisGestion ? computeFraisTTC(form.frais_gestion_mode_saisie, form.frais_gestion_valeur) : 0;
const totalFraisSource =
(hasFraisEntree && form.frais_entree_mode_reglement === 'source' ? fraisEntreeTTC : 0) +
(hasFraisGestion && form.frais_gestion_mode_reglement === 'source' ? fraisGestionTTC : 0);
const payload = {
plateforme_id: Number(form.plateforme_id),
investisseur_id: Number(form.investisseur_id) || undefined,
nom_deal: form.nom_deal,
strategie: form.strategie || undefined,
date_souscription: form.date_souscription,
duree_mois: form.duree_annees ? Math.round(Number(form.duree_annees) * 12) : undefined,
montant_investi: Number(form.montant_investi),
objectif: form.objectif || undefined,
statut: form.statut,
reference: form.reference || undefined,
notes: form.notes || undefined,
};
try {
await api.put(`/investissements-pe/${id}`, payload);
await upsertFrais(hasFraisEntree, fraisEntreeTTC, 'souscription', form.frais_entree_mode_reglement, form.frais_entree_compte_id, form.frais_entree_id);
await upsertFrais(hasFraisGestion, fraisGestionTTC, 'gestion', form.frais_gestion_mode_reglement, form.frais_gestion_compte_id, form.frais_gestion_id);
await syncValorisationInitiale(totalFraisSource, form.valorisation_initiale_id);
closeEditDeal();
await load();
} catch (e) { setFormErr(e.message); }
};
const onDeleteDeal = async () => {
try {
await api.del(`/investissements-pe/${id}`);
navigate('/investissements');
} catch (e) { setErr(e.message); }
};
/* ── Valorisations ── */
const [valForm, setValForm] = useState(emptyValForm);
const [valErr, setValErr] = useState(null);
const [valDeleteTarget, setValDeleteTarget] = useState(null);
const { run: runValSubmit, pending: valSubmitting } = useAsyncAction();
const submitVal = async (e) => {
e?.preventDefault?.(); setValErr(null);
try {
await api.post(`/investissements-pe/${id}/valorisations`, {
date_valorisation: valForm.date_valorisation,
valorisation_nette: Number(valForm.valorisation_nette),
notes: valForm.notes || undefined,
});
setValForm(emptyValForm);
await load();
} catch (e) { setValErr(e.message); }
};
const onDeleteVal = async () => {
if (!valDeleteTarget) return;
try {
await api.del(`/investissements-pe/${id}/valorisations/${valDeleteTarget.id}`);
setValDeleteTarget(null);
await load();
} catch (e) { setValErr(e.message); }
};
/* ── Frais d'opération complémentaires (gestion/distribution/autre) ── */
const [fraisModalOpen, setFraisModalOpen] = useState(false);
const [fraisEditingId, setFraisEditingId] = useState(null);
const [fraisForm, setFraisForm] = useState(emptyFraisForm);
const [fraisErr, setFraisErr] = useState(null);
const [fraisConfirmingDelete, setFraisConfirmingDelete] = useState(false);
const { run: runFraisSubmit, pending: fraisSubmitting } = useAsyncAction();
const openAddFrais = () => {
setFraisEditingId(null);
setFraisForm({ ...emptyFraisForm, date_operation: today() });
setFraisErr(null);
setFraisConfirmingDelete(false);
setFraisModalOpen(true);
};
const openEditFrais = (f) => {
setFraisEditingId(f.id);
setFraisForm({
montant: f.montant, mode_reglement: f.mode_reglement, compte_id: f.compte_id || '',
date_operation: f.date_operation, categorie: f.categorie || 'autre', notes: f.notes || '',
});
setFraisErr(null);
setFraisConfirmingDelete(false);
setFraisModalOpen(true);
};
const closeFraisModal = () => { setFraisModalOpen(false); setFraisErr(null); setFraisConfirmingDelete(false); };
const submitFrais = async (e) => {
e?.preventDefault?.(); setFraisErr(null);
try {
const payload = {
investissement_pe_id: Number(id),
montant: Number(fraisForm.montant),
mode_reglement: fraisForm.mode_reglement,
compte_id: fraisForm.mode_reglement === 'compte_courant' && fraisForm.compte_id ? Number(fraisForm.compte_id) : null,
date_operation: fraisForm.date_operation,
origine: 'manuel',
categorie: fraisForm.categorie || 'autre',
notes: fraisForm.notes || undefined,
};
if (fraisEditingId) await api.put(`/frais-operations/${fraisEditingId}`, payload);
else await api.post('/frais-operations', payload);
closeFraisModal();
await load();
} catch (e) { setFraisErr(e.message); }
};
const onDeleteFrais = async () => {
try {
await api.del(`/frais-operations/${fraisEditingId}`);
closeFraisModal();
await load();
} catch (e) { setFraisErr(e.message); }
};
/* ── Documents (réutilise le système générique /documents, entity_type 'investissement_pe',
* porté depuis InvestissementDetail.jsx en version simplifiée : bouton "+ Ajouter" direct
* plutôt que menu ⋮, pas d'action "tout supprimer" pour cette première passe) ── */
const [docsErr, setDocsErr] = useState(null);
const [uploadingDoc, setUploadingDoc] = useState(false);
const [renamingDocId, setRenamingDocId] = useState(null);
const [renameValue, setRenameValue] = useState('');
const [docDeleteTarget, setDocDeleteTarget] = useState(null);
const docFileInputRef = useRef(null);
const refreshDocuments = async () => {
try { setDocuments(await api.get('/documents', { entity_type: 'investissement_pe', entity_id: id })); }
catch { /* la fiche reste utilisable même si le rafraîchissement échoue */ }
};
const handleDocFileChange = async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setUploadingDoc(true); setDocsErr(null);
try {
const fd = new FormData();
fd.append('file', file);
fd.append('entity_type', 'investissement_pe');
fd.append('entity_id', id);
fd.append('categorie', 'investissement_pe');
fd.append('nom_affichage', file.name.replace(/\.[^./\\]+$/, ''));
await api.postForm('/documents', fd);
await refreshDocuments();
} catch (e) { setDocsErr(e.message); }
finally { setUploadingDoc(false); }
};
const handleDocRenameStart = (doc) => { setRenamingDocId(doc.id); setRenameValue(doc.nom_affichage); };
const handleDocRenameCommit = async (docId) => {
const value = renameValue.trim();
setRenamingDocId(null);
const current = documents.find(d => d.id === docId);
if (!value || !current || value === current.nom_affichage) return;
try { await api.put(`/documents/${docId}`, { nom_affichage: value }); await refreshDocuments(); }
catch (e) { setDocsErr(e.message); }
};
const handleDocDownload = async (doc) => {
try {
const blob = await api.blob(`/documents/${doc.id}/download`);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `${doc.nom_affichage}.${doc.extension}`; a.click();
URL.revokeObjectURL(url);
} catch (e) { setDocsErr(e.message); }
};
const handleDocDelete = async () => {
if (!docDeleteTarget) return;
try { await api.del(`/documents/${docDeleteTarget.id}`); setDocDeleteTarget(null); await refreshDocuments(); }
catch (e) { setDocsErr(e.message); }
};
if (loading) return <div style={{ padding: 32 }}>Chargement…</div>;
if (!deal) return (
<>
<div className="topbar"><h2>Investissement introuvable</h2></div>
{err && <div className="error" style={{ margin: '0 24px' }}>{err}</div>}
</>
);
const totalFrais = fraisOps.reduce((s, f) => s + (f.montant || 0), 0);
return (
<>
<div className="topbar">
<button onClick={() => navigate('/investissements')} style={{ marginRight: 12 }}>← Retour</button>
<h2><PageIcon name="investissement" />{deal.nom_deal}</h2>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={openEditDeal}>Modifier</button>
<button className="danger" onClick={() => setDeleteConfirm(true)}>Supprimer</button>
</div>
</div>
{err && <div className="error" style={{ margin: '0 24px 16px' }}>{err}</div>}
<div style={{ padding: '0 24px' }}>
{/* ── Résumé ── */}
<div className="card" style={{ marginBottom: 16 }}>
<div className="dr-kpi-row" style={{ marginBottom: 0 }}>
<div className="kpi"><div className="label">Montant investi</div>
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(deal.montant_investi)}</div></div>
<div className="kpi"><div className="label">Valorisation actuelle</div>
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(deal.derniere_valorisation ?? deal.montant_investi)}</div></div>
<div className="kpi"><div className="label">TVPI</div>
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtMultiple(deal.tvpi)}</div></div>
<div className="kpi"><div className="label">Distributions cumulées</div>
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(deal.distributions_cumulees)}</div></div>
<div className="kpi"><div className="label">Statut</div>
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{STATUT_LABELS[deal.statut] || deal.statut}</div></div>
</div>
<div className="text-muted" style={{ marginTop: 14, fontSize: 13 }}>
{deal.plateforme_nom} — {deal.investisseur_nom} · Souscrit le {fmtDate(deal.date_souscription)}
{deal.strategie ? ` · ${deal.strategie}` : ''}
{deal.objectif ? ` · Objectif : ${deal.objectif}` : ''}
{deal.reference ? ` · Réf. ${deal.reference}` : ''}
</div>
{deal.notes && <div className="text-muted" style={{ marginTop: 8, fontSize: 13 }}>{deal.notes}</div>}
</div>
{/* ── Valorisations ── */}
<div className="card" style={{ marginBottom: 16 }}>
<h3 style={{ marginTop: 0, marginBottom: 14 }}>Valorisations</h3>
{valErr && <div className="error">{valErr}</div>}
<form onSubmit={e => runValSubmit(submitVal, e)} className="form-grid" style={{ marginBottom: 16 }}>
<div>
<label>Date *</label>
<input type="date" required value={valForm.date_valorisation} onChange={e => setValForm({ ...valForm, date_valorisation: e.target.value })} />
</div>
<div>
<label>Valorisation nette (€) *</label>
<input type="number" step="0.01" min="0" required value={valForm.valorisation_nette} onChange={e => setValForm({ ...valForm, valorisation_nette: e.target.value })} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Notes</label>
<input value={valForm.notes} onChange={e => setValForm({ ...valForm, notes: e.target.value })} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<button type="submit" className="primary" disabled={valSubmitting}>
{valSubmitting ? 'Ajout…' : '+ Ajouter une valorisation'}
</button>
</div>
</form>
<table>
<thead><tr><th>Date</th><th className="num">Valorisation nette</th><th>Notes</th><th style={{ width: 40 }}></th></tr></thead>
<tbody>
{valorisations.length === 0 && (
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center' }}>Aucune valorisation enregistrée</td></tr>
)}
{valorisations.map(v => (
<tr key={v.id}>
<td>{fmtDate(v.date_valorisation)}</td>
<td className="num">{fmtEUR(v.valorisation_nette)}</td>
<td className="text-muted">{v.notes || '—'}</td>
<td><button className="icon-btn" title="Supprimer" onClick={() => setValDeleteTarget(v)}>✕</button></td>
</tr>
))}
</tbody>
</table>
</div>
{/* ── Remboursements ── */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Remboursements</h3>
<button onClick={() => navigate('/remboursements?new=1')}>+ Nouveau remboursement</button>
</div>
<table>
<thead><tr><th>Date</th><th className="num">Montant versé</th><th>Notes</th></tr></thead>
<tbody>
{remboursements.length === 0 && (
<tr><td colSpan={3} className="text-muted" style={{ textAlign: 'center' }}>Aucun remboursement enregistré pour ce deal</td></tr>
)}
{remboursements.map(r => (
<tr key={r.id} style={{ cursor: 'pointer' }} onClick={() => navigate('/remboursements')} title="Voir dans Remboursements">
<td>{fmtDate(r.date_remb)}</td>
<td className="num">{fmtEUR(r.net_recu)}</td>
<td className="text-muted">{r.notes || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* ── Frais d'opération ── */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>
Frais d'opération
{fraisOps.length > 0 && <span className="text-muted" style={{ fontWeight: 400, fontSize: 13, marginLeft: 8 }}>Total : {fmtEUR(totalFrais)}</span>}
</h3>
<button onClick={openAddFrais}>+ Ajouter un frais</button>
</div>
{fraisOps.length === 0 ? (
<div className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>Aucun frais journalisé pour l'instant.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{fraisOps.map(f => {
const isManaged = f.categorie === 'souscription' || (f.categorie === 'gestion' && f.origine === 'souscription');
return (
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ flex: 1, minWidth: 0, cursor: isManaged ? 'pointer' : 'pointer' }}
onClick={() => isManaged ? openEditDeal() : openEditFrais(f)}
title={isManaged ? "Modifiable depuis « Modifier l'investissement »" : 'Cliquer pour modifier'}>
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)' }}>
{fmtEUR(f.montant)}
<span className="text-muted" style={{ fontWeight: 400, marginLeft: 8 }}>
{FRAIS_MODE_LABELS[f.mode_reglement] || f.mode_reglement}
{f.mode_reglement === 'compte_courant' && f.compte_nom ? ` (${f.compte_nom})` : ''}
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{fmtDate(f.date_operation)} · {FRAIS_CATEGORIE_LABELS[f.categorie] || f.categorie}
{f.notes ? ` · ${f.notes}` : ''}
</div>
</div>
{!isManaged && (
<button className="icon-btn" title="Supprimer" onClick={() => { setFraisEditingId(f.id); onDeleteFrais(); }}>✕</button>
)}
</div>
);
})}
</div>
)}
</div>
{/* ── Documents ── */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Mes documents</h3>
<button onClick={() => docFileInputRef.current?.click()} disabled={uploadingDoc}>
{uploadingDoc ? 'Envoi…' : '+ Ajouter un document'}
</button>
</div>
{docsErr && <div className="error">{docsErr}</div>}
{documents.length === 0 ? (
<div className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>Aucun document pour l'instant.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{documents.map(doc => (
<div key={doc.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ flex: 1, minWidth: 0 }}>
{renamingDocId === doc.id ? (
<input autoFocus value={renameValue} onChange={e => setRenameValue(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleDocRenameCommit(doc.id); if (e.key === 'Escape') setRenamingDocId(null); }}
onBlur={() => handleDocRenameCommit(doc.id)} style={{ width: '100%', fontSize: 'var(--fs-sm)' }} />
) : (
<div onClick={() => handleDocRenameStart(doc)} title="Cliquer pour renommer"
style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }}>
{doc.nom_affichage}.{doc.extension}
</div>
)}
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{fmtOctets(doc.taille_octets)} · {fmtDate(doc.created_at)}</div>
</div>
<button className="icon-btn" title="Télécharger" onClick={() => handleDocDownload(doc)}>⬇</button>
<button className="icon-btn" title="Supprimer" onClick={() => setDocDeleteTarget(doc)}>✕</button>
</div>
))}
</div>
)}
<input ref={docFileInputRef} type="file" onChange={handleDocFileChange} style={{ display: 'none' }} />
</div>
</div>
<InvestissementPeFormModal
open={modalOpen}
editingId={id}
onClose={closeEditDeal}
form={form}
setForm={setForm}
plats={plats}
investisseurs={investisseurs}
multiDetenteur={new Set(plats.map(p => p.investisseur_id)).size > 1}
comptesInvestisseur={comptesInvestisseur}
err={formErr}
submitting={submitting}
onSubmit={() => runSubmit(submitDeal)}
onDelete={() => { closeEditDeal(); setDeleteConfirm(true); }}
investisseurForPlat={investisseurForPlat}
/>
<ConfirmModal
open={deleteConfirm}
title="Supprimer cet investissement ?"
message="Cette action est irréversible. Les distributions déjà saisies (Remboursements) sont conservées mais détachées de ce deal."
confirmLabel="Supprimer"
onConfirm={onDeleteDeal}
onCancel={() => setDeleteConfirm(false)}
/>
<ConfirmModal
open={!!valDeleteTarget}
title="Supprimer cette valorisation ?"
message="Cette action est irréversible."
confirmLabel="Supprimer"
onConfirm={onDeleteVal}
onCancel={() => setValDeleteTarget(null)}
/>
<ConfirmModal
open={!!docDeleteTarget}
title="Supprimer ce document ?"
message={docDeleteTarget ? `"${docDeleteTarget.nom_affichage}.${docDeleteTarget.extension}" sera définitivement supprimé.` : ''}
confirmLabel="Supprimer"
onConfirm={handleDocDelete}
onCancel={() => setDocDeleteTarget(null)}
/>
<FraisFormModal
open={fraisModalOpen}
onClose={closeFraisModal}
editingId={fraisEditingId}
form={fraisForm}
onFieldChange={(k, v) => setFraisForm(f => ({ ...f, [k]: v }))}
err={fraisErr}
onSubmit={e => runFraisSubmit(submitFrais, e)}
onDelete={() => runFraisSubmit(onDeleteFrais)}
submitting={fraisSubmitting}
comptesInvestisseur={comptesInvestisseur}
locked
investissementOptions={[]}
confirmingDelete={fraisConfirmingDelete}
setConfirmingDelete={setFraisConfirmingDelete}
/>
</>
);
}
+4 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { usePagination } from '../hooks/usePagination.js';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import Pagination from '../components/Pagination.jsx';
import PageIcon from '../components/PageIcon.jsx';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
@@ -296,6 +297,7 @@ export default function Investissements() {
const [form, setForm] = useState(empty);
const [err, setErr] = useState(null);
const [loading, setLoading] = useState(false);
const { run: runSubmit, pending: submitting } = useAsyncAction();
const [deleteConfirm, setDeleteConfirm] = useState(null);
const [openMenu, setOpenMenu] = useState(null);
// Proposition de dépôt après création d'un investissement si le porte-monnaie ne
@@ -1437,7 +1439,8 @@ export default function Investissements() {
open={modalOpen}
editingId={editingId}
onClose={close}
onSubmit={submit}
onSubmit={e => runSubmit(submit, e)}
submitting={submitting}
form={form}
onFieldChange={(key, value) => setForm(f => ({ ...f, [key]: value }))}
onFormPatch={patch => setForm(f => typeof patch === 'function' ? patch(f) : { ...f, ...patch })}
+938
View File
@@ -0,0 +1,938 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useWorkspace } from '../context/WorkspaceContext.jsx';
import Modal from '../components/Modal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx';
import EmptyState from '../components/EmptyState.jsx';
import PageIcon from '../components/PageIcon.jsx';
import ValorisationPeChart from '../components/ValorisationPeChart.jsx';
import DistributionChart from '../components/DistributionChart.jsx';
import ValorisationMensuelleTable from '../components/ValorisationMensuelleTable.jsx';
import InvestissementPeFormModal from '../components/InvestissementPeFormModal.jsx';
import FraisFormModal from '../components/FraisFormModal.jsx';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import { fmtEUR, fmtDate, today } from '../utils/format.js';
const STATUT_LABELS = {
en_attente: 'En attente',
valide: 'Validé',
cloture: 'Clôturé',
perte_definitive: 'Perte définitive',
};
export const emptyDealForm = {
plateforme_id: '',
investisseur_id: '',
nom_deal: '',
strategie: '',
date_souscription: today(),
duree_annees: '',
montant_investi: '',
objectif: '',
statut: 'valide',
reference: '',
notes: '',
// Étape "Frais" de la modale (chantier 17/09/26, demande Olivier — captures Fundora) : deux
// lignes indépendantes journalisées comme deux entrées frais_operations distinctes. Frais
// d'entrée → catégorie 'souscription' (unique par deal) ; Frais de gestion → catégorie
// 'gestion', origine 'souscription' (distingue cette ligne d'un frais de gestion ajouté plus
// tard depuis la page Frais PE ou la liste des investissements).
frais_entree_mode_saisie: 'montant', frais_entree_valeur: '', frais_entree_mode_reglement: 'source', frais_entree_compte_id: '', frais_entree_id: null,
frais_gestion_mode_saisie: 'montant', frais_gestion_valeur: '', frais_gestion_mode_reglement: 'source', frais_gestion_compte_id: '', frais_gestion_id: null,
// Id de la valorisation "initiale" auto-générée à la date de souscription pour refléter les
// frais réglés "à la source" (cf. AUTO_VAL_NOTE / submit ci-dessous) — null si aucune n'existe
// encore pour ce deal. Décision Olivier 17/09/26 : le montant investi (souscrit) ne bouge
// JAMAIS, c'est la valorisation qui encaisse immédiatement les frais d'entrée/de gestion à la
// date de souscription — jusqu'à ce qu'une vraie valorisation du fonds vienne la remplacer.
valorisation_initiale_id: null,
};
// Marqueur de la valorisation "initiale" auto-générée par l'étape "Frais" de la modale (cf.
// submit ci-dessous) — permet de la retrouver/remplacer sans toucher aux valorisations saisies
// manuellement par ailleurs (modale "Valorisations" annexe).
export const AUTO_VAL_NOTE = 'Valorisation initiale (frais de souscription)';
// Accès rapide "+ frais" depuis une ligne de la liste des investissements (chantier "frais PE",
// 17/09/26, demande Olivier) — permet d'ajouter un frais complémentaire (gestion récurrente,
// distribution, autre) sans passer par la page Frais PE. Catégorie par défaut 'gestion' :
// 'souscription' reste exclusivement gérée par l'étape "Frais" de la modale d'investissement
// (cf. FraisFormModal.jsx, qui exclut déjà cette catégorie de son sélecteur).
const emptyFraisAddForm = { montant: '', mode_reglement: 'source', compte_id: '', date_operation: today(), categorie: 'gestion', notes: '' };
const fmtMultiple = (n) => (n != null ? `x${n.toFixed(2).replace('.', ',')}` : '—');
/* ── Indicateur de progression (copié de pages/Investissements.jsx —
* pas exporté par un composant partagé) ── */
function TrendBadge({ current, prev, invert = false }) {
if (prev == null || Math.abs(prev) < 0.005) return null;
const diff = current - prev;
const pct = (diff / prev) * 100;
const up = diff > 0;
const neutral = diff === 0;
const good = neutral ? null : (invert ? !up : up);
const color = neutral ? 'var(--text-muted)' : good ? '#16a34a' : '#dc2626';
const bg = neutral ? 'var(--surface-2)' : good ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)';
const arrow = neutral ? '→' : up ? '↗' : '↘';
const label = `${up ? '+' : ''}${Math.abs(pct) < 10 ? pct.toFixed(1) : Math.round(pct)}%`;
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '2px 8px', borderRadius: 20,
background: bg, color, fontSize: '0.76em', fontWeight: 600,
whiteSpace: 'nowrap',
}}>
{arrow} {label}
</span>
);
}
/**
* Investissements — Private Equity (17/09/26, cf.
* project_workspaces_transformation.md). Sert tout workspace autre que
* crowdlending (cf. InvestissementsRouter dans App.jsx, jamais lié en dur à
* un slug précis), sur une table dédiée investissements_pe : un deal PE n'a
* ni échéancier connu à l'avance ni taux d'intérêt fixe — c'est un montant
* unique investi (le porte-monnaie diminue d'un coup, comme en crowdlending,
* décision Olivier), valorisé périodiquement par le fonds. Les distributions
* (retour de capital, plus-value) se saisissent depuis la page Remboursements
* (types bonus_plateforme / intérêts_plateforme), avec un lien optionnel vers
* le deal pour calculer son TVPI.
*
* Depuis le 17/09/26 (demande d'Olivier : "modifier la page investissements
* de PE pour qu'elle ressemble à celle de crowdlending"), cette page reprend
* le graphique cumulé, la Distribution par plateforme, 5 KPIs et les onglets
* Plateformes / Vision mensuelle / Investissements de pages/Investissements.jsx
* (crowdlending) — MAIS avec une sémantique adaptée au modèle PE (décisions
* Olivier du 17/09/26, cf. les 4 questions posées avant l'implémentation) :
* - Le graphique et la 1ère carte KPI n'affichent pas un "capital en cours"
* (qui décroît avec les remboursements, notion sans équivalent en PE
* puisque montant_investi n'est jamais décrémenté) mais la VALORISATION
* TOTALE du portefeuille : pour chaque deal, sa dernière valeur connue
* (montant_investi à la souscription, puis chaque valorisation saisie la
* remplace), sommée sur tous les deals — cf. ValorisationPeChart.jsx.
* - La 2e carte "Investissements à risque" devient "Pertes définitives"
* (somme du montant investi des deals au statut perte_definitive) — PE n'a
* pas de notion de retard/défaut comparable au crowdlending.
* - Les cartes 4 et 5 ("Investissements remboursés" / "Intérêts perçus" en
* crowdlending, deux notions distinctes) deviennent "Distributions
* cumulées" (retour de cash du fonds, capital et plus-value confondus —
* pas de séparation en PE) et "TVPI moyen" (multiple pondéré du
* portefeuille : (valorisation + distributions) / capital investi).
* - L'onglet "Vision mensuelle" devient un tableau de VALORISATION mensuelle
* par plateforme (ValorisationMensuelleTable.jsx), et non plus un capital
* en cours basé sur des remboursements qui n'existent pas pour ces deals.
*
* Approximation assumée pour les comparaisons N/N-1 (TrendBadge) sur les
* cartes Distributions et TVPI : distributions_cumulees (renvoyé par
* GET /investissements-pe) est une valeur globale, non bornée à une date —
* la comparaison restreint seulement l'ENSEMBLE des deals pris en compte
* (souscrits avant la coupure), pas le montant des distributions elles-
* mêmes à cette date. Seule la valorisation (dont l'historique daté est
* connu via GET /investissements-pe/valorisations) est bornée exactement à
* la coupure. Même principe d'approximation que "Investissements à risque"
* côté crowdlending, qui utilise déjà le statut ACTUEL plutôt qu'un statut
* historique à la date de coupure.
*/
export default function InvestissementsPe() {
const { activeId: investisseurId, investisseurs } = useInvestisseur();
const { active: activeWorkspace } = useWorkspace() || {};
const location = useLocation();
const navigate = useNavigate();
const [rows, setRows] = useState([]);
const [allPlats, setAllPlats] = useState([]);
const [valorisations, setValorisations] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(emptyDealForm);
const [deleteTarget, setDeleteTarget] = useState(null);
const { run: runSubmit, pending: submitting } = useAsyncAction();
// Comptes du détenteur sélectionné dans le formulaire — nécessaires pour l'étape "Frais"
// (option de règlement "compte courant du détenteur"), même mécanisme que Frais.jsx /
// Investissements.jsx côté crowdlending (route déjà générique, indépendante du workspace).
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
// Modale annexe "+ frais" (accès rapide depuis une ligne de la liste des investissements)
const [fraisAddDeal, setFraisAddDeal] = useState(null);
const [fraisAddForm, setFraisAddForm] = useState(emptyFraisAddForm);
const [fraisAddErr, setFraisAddErr] = useState(null);
const [fraisAddComptes, setFraisAddComptes] = useState([]);
const { run: runFraisAddSubmit, pending: fraisAddSubmitting } = useAsyncAction();
const [activeTab, setActiveTab] = useState('plateformes');
const [filter, setFilter] = useState({ statut: '', plateforme_id: '' });
const [platYear, setPlatYear] = useState('');
const [listFocused, setListFocused] = useState(false);
// Valorisations : petite modale annexe, ouverte depuis une ligne du tableau.
// Plateformes éligibles au workspace actif uniquement (GET /plateformes
// n'est pas filtré côté serveur — seulement enrichi de row.workspaces),
// même patron que DepotsRetraitsSimple / RemboursementsSimple.
const plats = useMemo(
() => allPlats.filter(p => (p.workspaces || []).some(w => w.id === activeWorkspace?.id)),
[allPlats, activeWorkspace],
);
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
const load = async () => {
setLoading(true);
try {
const [inv, pl, val] = await Promise.all([
api.get('/investissements-pe'),
api.get('/plateformes'),
api.get('/investissements-pe/valorisations'),
]);
setRows(inv);
setAllPlats(pl);
setValorisations(val);
} catch (e) {
setErr(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (investisseurId) load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [investisseurId, activeWorkspace?.id]);
useEffect(() => {
if (!form.investisseur_id || !modalOpen) { setComptesInvestisseur([]); return; }
api.get(`/investissements/comptes-par-investisseur/${form.investisseur_id}`)
.then(setComptesInvestisseur)
.catch(() => setComptesInvestisseur([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form.investisseur_id, modalOpen]);
useEffect(() => {
if (!fraisAddDeal?.investisseur_id) { setFraisAddComptes([]); return; }
api.get(`/investissements/comptes-par-investisseur/${fraisAddDeal.investisseur_id}`)
.then(setFraisAddComptes)
.catch(() => setFraisAddComptes([]));
}, [fraisAddDeal?.investisseur_id]);
const openFraisAdd = (deal) => {
setFraisAddDeal(deal);
setFraisAddForm({ ...emptyFraisAddForm, date_operation: today() });
setFraisAddErr(null);
};
const closeFraisAdd = () => { setFraisAddDeal(null); setFraisAddErr(null); };
const submitFraisAdd = async (e) => {
e?.preventDefault?.(); setFraisAddErr(null);
try {
await api.post('/frais-operations', {
investissement_pe_id: fraisAddDeal.id,
montant: Number(fraisAddForm.montant),
mode_reglement: fraisAddForm.mode_reglement,
compte_id: fraisAddForm.mode_reglement === 'compte_courant' && fraisAddForm.compte_id ? Number(fraisAddForm.compte_id) : null,
date_operation: fraisAddForm.date_operation,
origine: 'manuel',
categorie: fraisAddForm.categorie,
notes: fraisAddForm.notes || undefined,
});
closeFraisAdd();
} catch (e) { setFraisAddErr(e.message); }
};
const investisseurForPlat = (platId) => {
const plat = plats.find(p => String(p.id) === String(platId));
return plat?.investisseur_id ? String(plat.investisseur_id) : String(investisseurId || '');
};
const openAdd = () => {
setEditingId(null);
const firstId = plats[0]?.id || '';
setForm({ ...emptyDealForm, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) });
setErr(null);
setModalOpen(true);
};
// Ouverture auto depuis le bouton global "Ajout Investissement" (topbar/burger,
// cf. Layout.jsx — même ?new=1 que les pages Dépôts/Retraits et Remboursements).
const pendingNew = useRef(false);
useEffect(() => {
const params = new URLSearchParams(location.search);
if (params.get('new') !== '1') return;
navigate('/investissements', { replace: true });
if (plats.length > 0) {
openAdd();
} else {
pendingNew.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.search]);
useEffect(() => {
if (pendingNew.current && plats.length > 0) {
pendingNew.current = false;
openAdd();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [plats.length]);
const openEdit = async (r) => {
setEditingId(r.id);
setForm({
plateforme_id: String(r.plateforme_id || ''),
investisseur_id: String(r.investisseur_id || ''),
nom_deal: r.nom_deal,
strategie: r.strategie || '',
date_souscription: r.date_souscription,
duree_annees: r.duree_mois ? String(Math.round((r.duree_mois / 12) * 10) / 10) : '',
montant_investi: String(r.montant_investi ?? ''),
objectif: r.objectif || '',
statut: r.statut,
reference: r.reference || '',
notes: r.notes || '',
frais_entree_mode_saisie: 'montant', frais_entree_valeur: '', frais_entree_mode_reglement: 'source', frais_entree_compte_id: '', frais_entree_id: null,
frais_gestion_mode_saisie: 'montant', frais_gestion_valeur: '', frais_gestion_mode_reglement: 'source', frais_gestion_compte_id: '', frais_gestion_id: null,
});
setErr(null);
setModalOpen(true);
// Pré-remplissage best-effort des frais déjà journalisés + de la valorisation "initiale"
// auto-générée (cf. AUTO_VAL_NOTE/submit) pour ce deal — n'empêche jamais l'ouverture de la
// modale si l'appel échoue.
try {
const [fraisOps, valRows] = await Promise.all([
api.get('/frais-operations', { investissement_pe_id: r.id }),
api.get(`/investissements-pe/${r.id}/valorisations`),
]);
const entree = fraisOps.find(f => f.categorie === 'souscription');
const gestion = fraisOps.find(f => f.categorie === 'gestion' && f.origine === 'souscription');
const autoVal = valRows.find(v => v.notes === AUTO_VAL_NOTE);
setForm(f => ({
...f,
valorisation_initiale_id: autoVal ? autoVal.id : null,
...(entree ? {
frais_entree_valeur: String(entree.montant), frais_entree_mode_reglement: entree.mode_reglement,
frais_entree_compte_id: entree.compte_id ? String(entree.compte_id) : '', frais_entree_id: entree.id,
} : {}),
...(gestion ? {
frais_gestion_valeur: String(gestion.montant), frais_gestion_mode_reglement: gestion.mode_reglement,
frais_gestion_compte_id: gestion.compte_id ? String(gestion.compte_id) : '', frais_gestion_id: gestion.id,
} : {}),
}));
} catch { /* non bloquant — rattrapable depuis la page Frais PE / la modale Valorisations */ }
};
const close = () => { setModalOpen(false); setEditingId(null); setForm(emptyDealForm); setErr(null); };
// Calcule le TTC d'une ligne de frais depuis son mode de saisie (montant fixe ou % du
// montant investi) — même formule que Investissements.jsx (crowdlending).
const computeFraisTTC = (modeSaisie, valeur) => {
if (valeur === '' || Number(valeur) <= 0) return 0;
return Math.round((modeSaisie === 'pourcentage'
? Number(form.montant_investi) * Number(valeur) / 100
: Number(valeur)) * 100) / 100;
};
// Upsert best-effort d'une ligne frais_operations (PUT si déjà chargée via *_id, POST sinon ;
// DELETE si le montant a été vidé alors qu'une entrée existait) — n'empêche jamais
// l'enregistrement principal du deal, rattrapable a posteriori depuis la page Frais PE.
const upsertFrais = async (dealId, has, ttc, categorie, modeReglement, compteId, fraisId) => {
try {
if (has) {
const fraisPayload = {
investissement_pe_id: dealId,
montant: ttc,
mode_reglement: modeReglement,
compte_id: modeReglement === 'compte_courant' && compteId ? Number(compteId) : null,
date_operation: form.date_souscription,
origine: 'souscription',
categorie,
};
if (fraisId) await api.put(`/frais-operations/${fraisId}`, fraisPayload);
else await api.post('/frais-operations', fraisPayload);
} else if (fraisId) {
await api.del(`/frais-operations/${fraisId}`);
}
} catch { /* non bloquant */ }
};
// Gère la valorisation "initiale" auto-générée à la date de souscription (cf. AUTO_VAL_NOTE) :
// le montant investi (souscrit) ne bouge jamais, c'est cette valorisation qui encaisse
// immédiatement les frais réglés "à la source" — décision Olivier 17/09/26. Comme l'API ne
// propose pas de PUT sur une valorisation, on supprime l'éventuelle ligne existante puis on
// recrée à neuf (couvre aussi le cas où la date de souscription a changé). Best-effort, jamais
// bloquant pour l'enregistrement principal du deal.
const syncValorisationInitiale = async (dealId, totalFraisSource, valorisationInitialeId) => {
try {
if (valorisationInitialeId) {
await api.del(`/investissements-pe/${dealId}/valorisations/${valorisationInitialeId}`);
}
if (totalFraisSource > 0) {
const initiale = Math.max(0, Math.round((Number(form.montant_investi) - totalFraisSource) * 100) / 100);
await api.post(`/investissements-pe/${dealId}/valorisations`, {
date_valorisation: form.date_souscription,
valorisation_nette: initiale,
notes: AUTO_VAL_NOTE,
});
}
} catch { /* non bloquant — rattrapable depuis la modale Valorisations */ }
};
const submit = async (e) => {
e?.preventDefault?.(); setErr(null);
const platFraisType = plats.find(p => String(p.id) === String(form.plateforme_id))?.frais_plateforme;
const fraisApplicable = !!platFraisType && platFraisType !== 'aucun';
const hasFraisEntree = fraisApplicable && form.frais_entree_valeur !== '' && Number(form.frais_entree_valeur) > 0;
const hasFraisGestion = fraisApplicable && form.frais_gestion_valeur !== '' && Number(form.frais_gestion_valeur) > 0;
const fraisEntreeTTC = hasFraisEntree ? computeFraisTTC(form.frais_entree_mode_saisie, form.frais_entree_valeur) : 0;
const fraisGestionTTC = hasFraisGestion ? computeFraisTTC(form.frais_gestion_mode_saisie, form.frais_gestion_valeur) : 0;
// Total des frais réglés "à la source" (aucun mouvement de trésorerie réel) — c'est ce
// montant qui vient réduire la VALORISATION à la date de souscription (cf.
// syncValorisationInitiale), jamais le montant investi lui-même (décision Olivier 17/09/26).
const totalFraisSource =
(hasFraisEntree && form.frais_entree_mode_reglement === 'source' ? fraisEntreeTTC : 0) +
(hasFraisGestion && form.frais_gestion_mode_reglement === 'source' ? fraisGestionTTC : 0);
const payload = {
plateforme_id: Number(form.plateforme_id),
investisseur_id: Number(form.investisseur_id) || undefined,
nom_deal: form.nom_deal,
strategie: form.strategie || undefined,
date_souscription: form.date_souscription,
duree_mois: form.duree_annees ? Math.round(Number(form.duree_annees) * 12) : undefined,
montant_investi: Number(form.montant_investi),
objectif: form.objectif || undefined,
statut: form.statut,
reference: form.reference || undefined,
notes: form.notes || undefined,
};
try {
let dealId = editingId;
if (editingId) {
await api.put(`/investissements-pe/${editingId}`, payload);
} else {
const created = await api.post('/investissements-pe', payload);
dealId = created.id;
}
await upsertFrais(dealId, hasFraisEntree, fraisEntreeTTC, 'souscription', form.frais_entree_mode_reglement, form.frais_entree_compte_id, form.frais_entree_id);
await upsertFrais(dealId, hasFraisGestion, fraisGestionTTC, 'gestion', form.frais_gestion_mode_reglement, form.frais_gestion_compte_id, form.frais_gestion_id);
await syncValorisationInitiale(dealId, totalFraisSource, form.valorisation_initiale_id);
close();
await load();
} catch (e) { setErr(e.message); }
};
const onDelete = async () => {
if (!deleteTarget) return;
try {
await api.del(`/investissements-pe/${deleteTarget.id}`);
setDeleteTarget(null);
await load();
} catch (e) { setErr(e.message); }
};
const sorted = useMemo(
() => [...rows].sort((a, b) => b.date_souscription.localeCompare(a.date_souscription) || b.id - a.id),
[rows],
);
/* ── Années disponibles (sélecteur Plateformes) ── */
const years = useMemo(
() => [...new Set(rows.map(r => r.date_souscription?.slice(0, 4)).filter(Boolean))].sort().reverse(),
[rows],
);
/* ── Historique de valorisations trié, par deal (pour bornage à une
* coupure — TrendBadge N/N-1 et graphique) ── */
const valorisationsByDeal = useMemo(() => {
const map = {};
for (const v of valorisations) {
const d = v.date_valorisation?.slice(0, 10);
if (!d || v.valorisation_nette == null) continue;
if (!map[v.investissement_pe_id]) map[v.investissement_pe_id] = [];
map[v.investissement_pe_id].push({ date: d, value: v.valorisation_nette });
}
for (const k in map) map[k].sort((a, b) => a.date.localeCompare(b.date));
return map;
}, [valorisations]);
/* Valorisation d'un deal à une coupure donnée (ou dernière connue si
* cutoff est null) — jamais < montant_investi tant qu'aucune valorisation
* n'a été saisie. */
const valorisationAtCutoff = (deal, cutoff) => {
if (!cutoff) return deal.derniere_valorisation ?? deal.montant_investi;
let val = deal.montant_investi;
for (const v of (valorisationsByDeal[deal.id] || [])) {
if (v.date <= cutoff) val = v.value; else break;
}
return val;
};
/* ── Totaux KPI (plateforme + année sélectionnées) ── */
const kpiRows = useMemo(() => rows.filter(r => {
if (filter.plateforme_id && String(r.plateforme_id) !== String(filter.plateforme_id)) return false;
if (platYear && r.date_souscription > `${platYear}-12-31`) return false;
return true;
}), [rows, filter.plateforme_id, platYear]);
const cutoff = platYear ? `${platYear}-12-31` : null;
const totals = useMemo(() => kpiRows.reduce((acc, r) => {
acc.investi += r.montant_investi;
acc.valorisation += valorisationAtCutoff(r, cutoff);
acc.distributions += r.distributions_cumulees || 0;
if (r.statut === 'perte_definitive') acc.pertes += r.montant_investi;
return acc;
}, { investi: 0, valorisation: 0, distributions: 0, pertes: 0 }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[kpiRows, cutoff, valorisationsByDeal]);
totals.tvpi = totals.investi > 0 ? (totals.valorisation + totals.distributions) / totals.investi : null;
const prevYear = platYear ? String(Number(platYear) - 1) : null;
const prevCutoff = prevYear ? `${prevYear}-12-31` : null;
const prevRows = useMemo(() => {
if (!platYear) return [];
return rows.filter(r => {
if (filter.plateforme_id && String(r.plateforme_id) !== String(filter.plateforme_id)) return false;
return r.date_souscription <= prevCutoff;
});
}, [rows, filter.plateforme_id, platYear, prevCutoff]);
const prevTotals = useMemo(() => prevRows.reduce((acc, r) => {
acc.investi += r.montant_investi;
acc.valorisation += valorisationAtCutoff(r, prevCutoff);
acc.distributions += r.distributions_cumulees || 0;
if (r.statut === 'perte_definitive') acc.pertes += r.montant_investi;
return acc;
}, { investi: 0, valorisation: 0, distributions: 0, pertes: 0 }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[prevRows, prevCutoff, valorisationsByDeal]);
prevTotals.tvpi = prevTotals.investi > 0 ? (prevTotals.valorisation + prevTotals.distributions) / prevTotals.investi : null;
/* ── Données graphique / Distribution : filtrées par plateforme + année,
* sans le filtre statut (comme Investissements.jsx côté crowdlending) ── */
const chartRows = useMemo(() => rows.filter(r => {
if (filter.plateforme_id && String(r.plateforme_id) !== String(filter.plateforme_id)) return false;
if (platYear && r.date_souscription > `${platYear}-12-31`) return false;
return true;
}), [rows, filter.plateforme_id, platYear]);
const distRows = useMemo(() => chartRows
.map(r => ({ plateforme_nom: r.plateforme_nom, montant: valorisationAtCutoff(r, cutoff) }))
.filter(r => r.montant > 0),
// eslint-disable-next-line react-hooks/exhaustive-deps
[chartRows, cutoff, valorisationsByDeal]);
/* ── Onglet Plateformes : indépendant du filtre plateforme (comme
* Investissements.jsx), seulement borné par l'année sélectionnée ── */
const yearMembers = useMemo(() => rows.filter(r => !platYear || r.date_souscription <= `${platYear}-12-31`), [rows, platYear]);
const platData = useMemo(() => {
const totalInvesti = yearMembers.reduce((s, r) => s + r.montant_investi, 0);
const byPlat = {};
for (const r of yearMembers) {
if (!byPlat[r.plateforme_id]) {
byPlat[r.plateforme_id] = {
plateforme_id: r.plateforme_id, nom: r.plateforme_nom,
detenteur_nom: r.investisseur_nom || null,
count: 0, investi: 0, valorisation: 0, distributions: 0, pertes: 0,
};
}
const p = byPlat[r.plateforme_id];
p.count++;
p.investi += r.montant_investi;
p.valorisation += valorisationAtCutoff(r, cutoff);
p.distributions += r.distributions_cumulees || 0;
if (r.statut === 'perte_definitive') p.pertes += r.montant_investi;
}
return Object.values(byPlat)
.sort((a, b) => b.investi - a.investi)
.map(p => ({
...p,
tvpi: p.investi > 0 ? (p.valorisation + p.distributions) / p.investi : null,
poids: totalInvesti > 0 ? (p.investi / totalInvesti) * 100 : 0,
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [yearMembers, cutoff, valorisationsByDeal]);
const platTotals = useMemo(() => platData.reduce((acc, p) => {
acc.count += p.count; acc.investi += p.investi;
acc.valorisation += p.valorisation; acc.distributions += p.distributions;
return acc;
}, { count: 0, investi: 0, valorisation: 0, distributions: 0 }), [platData]);
platTotals.tvpi = platTotals.investi > 0 ? (platTotals.valorisation + platTotals.distributions) / platTotals.investi : null;
/* ── Onglet Investissements : liste des deals, filtrée plateforme + statut ── */
const filteredRows = useMemo(() => sorted.filter(r => {
if (filter.plateforme_id && String(r.plateforme_id) !== String(filter.plateforme_id)) return false;
if (filter.statut && r.statut !== filter.statut) return false;
return true;
}), [sorted, filter]);
const hasFilter = !!(filter.statut || filter.plateforme_id);
const clearFilter = () => setFilter({ statut: '', plateforme_id: '' });
if (!loading && plats.length === 0) return (
<>
<div className="topbar"><h2><PageIcon name="investissement" />Investissements</h2></div>
<EmptyState
title="Aucune plateforme dans cet espace de travail"
message={`Ajoutez d'abord une plateforme à ${activeWorkspace?.libelle_menu || activeWorkspace?.nom || 'ce workspace'} depuis Mes plateformes pour pouvoir y enregistrer des investissements.`}
cta="Configurer mes plateformes"
to="/settings?section=plateformes&openAdd=1"
/>
</>
);
if (!loading && !modalOpen && sorted.length === 0) return (
<>
<div className="topbar"><h2><PageIcon name="investissement" />Investissements</h2></div>
<EmptyState
icon="📈"
title="Aucun investissement enregistré"
message="Vos plateformes sont configurées, mais aucun investissement n'a encore été enregistré."
cta="Ajouter un investissement"
to="/investissements?new=1"
/>
<InvestissementPeFormModal
open={modalOpen}
editingId={editingId}
onClose={close}
form={form}
setForm={setForm}
plats={plats}
investisseurs={investisseurs}
multiDetenteur={multiDetenteur}
comptesInvestisseur={comptesInvestisseur}
err={err}
submitting={submitting}
onSubmit={() => runSubmit(submit)}
onDelete={() => { const target = { id: editingId }; close(); setDeleteTarget(target); }}
investisseurForPlat={investisseurForPlat}
/>
</>
);
return (
<>
<div className="topbar">
<h2><PageIcon name="investissement" />Investissements</h2>
{hasFilter && (
<button className="dr-clear-btn" onClick={clearFilter}>✕ Effacer les filtres</button>
)}
</div>
{err && <div className="error" style={{ margin: '0 0 16px' }}>{err}</div>}
{/* ── Graphiques ── */}
{!listFocused && <div className="charts-row">
<ValorisationPeChart rows={chartRows} valorisations={valorisations} platYear={platYear} />
<DistributionChart rows={distRows} />
</div>}
{/* ── KPIs ── */}
{!listFocused && <div className="dr-kpi-row" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
{/* 1 — Valorisation totale */}
<div className="kpi">
<div className="label">Valorisation totale</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.valorisation)}</span>
{platYear && <TrendBadge current={totals.valorisation} prev={prevTotals.valorisation} />}
</div>
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.valorisation)} en {prevYear}</div>}
</div>
{/* 2 — Pertes définitives */}
<div
className={`kpi${totals.pertes > 0 ? ' dr-kpi-clickable' : ''}${filter.statut === 'perte_definitive' ? ' dr-kpi-active dr-kpi-active-danger' : ''}`}
onClick={() => {
if (totals.pertes === 0) return;
setFilter(f => ({ ...f, statut: f.statut === 'perte_definitive' ? '' : 'perte_definitive' }));
setActiveTab('investissements');
}}
title={totals.pertes > 0 ? 'Filtrer les deals en perte définitive' : undefined}
style={totals.pertes > 0 ? { cursor: 'pointer' } : {}}
>
<div className="label">Pertes définitives</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }} className={totals.pertes > 0 ? 'danger' : ''}>{fmtEUR(totals.pertes)}</span>
{platYear && totals.pertes > 0 && <TrendBadge current={totals.pertes} prev={prevTotals.pertes} invert={true} />}
</div>
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.pertes)} en {prevYear}</div>}
</div>
{/* 3 — Investissements depuis le début */}
<div
className={`kpi${hasFilter ? ' dr-kpi-clickable dr-kpi-active dr-kpi-active-neutral' : ''}`}
onClick={() => hasFilter && clearFilter()}
title={hasFilter ? 'Effacer les filtres' : undefined}
style={hasFilter ? { cursor: 'pointer' } : {}}
>
<div className="label">Investissements depuis le début</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.investi)}</span>
{platYear && <TrendBadge current={totals.investi} prev={prevTotals.investi} />}
</div>
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.investi)} en {prevYear}</div>}
</div>
{/* 4 — Distributions cumulées */}
<div className="kpi">
<div className="label">Distributions cumulées</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.distributions)}</span>
{platYear && <TrendBadge current={totals.distributions} prev={prevTotals.distributions} />}
</div>
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.distributions)} en {prevYear}</div>}
</div>
{/* 5 — TVPI moyen */}
<div className="kpi" title="Multiple pondéré du portefeuille : (valorisation + distributions) / capital investi">
<div className="label">TVPI moyen</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtMultiple(totals.tvpi)}</span>
{platYear && totals.tvpi != null && <TrendBadge current={totals.tvpi} prev={prevTotals.tvpi} />}
</div>
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtMultiple(prevTotals.tvpi)} en {prevYear}</div>}
</div>
</div>}
{/* ── Onglets ── */}
{!listFocused && <div className="dr-tabs">
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
onClick={() => setActiveTab('plateformes')}>Plateformes</button>
<button className={`dr-tab${activeTab === 'vision-mensuelle' ? ' active' : ''}`}
onClick={() => setActiveTab('vision-mensuelle')}>Vision mensuelle</button>
<button className={`dr-tab${activeTab === 'investissements' ? ' active' : ''}`}
onClick={() => setActiveTab('investissements')}>Investissements</button>
</div>}
{/* ====== ONGLET PLATEFORMES ====== */}
{activeTab === 'plateformes' && (
<div style={{ padding: '0 24px' }}>
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>
Répartition par plateforme
{platYear && <span style={{ marginLeft: 8 }}>pour l'année {platYear}</span>}
</h3>
<select value={platYear} onChange={e => setPlatYear(e.target.value)}
style={{
fontSize: 'var(--fs-xs)', padding: '4px 8px', height: 30,
borderRadius: 6, border: '1px solid var(--border)',
background: 'var(--surface-2)', color: 'var(--text-muted)',
cursor: 'pointer', outline: 'none',
}}>
<option value="">Toutes les années</option>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
{!rows.length && loading ? (
<div className="text-muted" style={{ padding: '12px 0' }}>Chargement…</div>
) : (
<table>
<thead>
<tr>
<th>Plateforme</th>
{multiDetenteur && <th>Détenteur</th>}
<th className="num">Deals</th>
<th className="num">Montant investi</th>
<th className="num">Valorisation actuelle</th>
<th className="num">Distributions</th>
<th className="num">TVPI</th>
<th className="num">Poids</th>
<th style={{ width: 28 }}></th>
</tr>
</thead>
<tbody>
{platData.length === 0 && (
<tr><td colSpan={multiDetenteur ? 9 : 8} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
Aucun investissement
</td></tr>
)}
{platData.map(p => {
const isActive = String(filter.plateforme_id) === String(p.plateforme_id);
return (
<tr key={p.plateforme_id}
className={`dr-row${isActive ? ' dr-row-selected' : ''}`}
style={{ cursor: 'pointer' }}
onClick={() => {
setFilter(f => ({ ...f, plateforme_id: isActive ? '' : String(p.plateforme_id) }));
setActiveTab('investissements');
}}>
<td><span style={{ fontWeight: isActive ? 600 : undefined }}>{p.nom}</span></td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>}
<td className="num">{p.count}</td>
<td className="num">{fmtEUR(p.investi)}</td>
<td className="num">{fmtEUR(p.valorisation)}</td>
<td className="num">{fmtEUR(p.distributions)}</td>
<td className="num">{fmtMultiple(p.tvpi)}</td>
<td className="num">
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
<div style={{ width: `${Math.max(0, p.poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
</div>
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>{p.poids.toFixed(1)} %</span>
</div>
</td>
<td style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: 11 }}>{isActive ? '✕' : ''}</td>
</tr>
);
})}
</tbody>
{platData.length > 1 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td>Total — {platData.length} plateformes</td>
{multiDetenteur && <td />}
<td className="num">{platTotals.count}</td>
<td className="num">{fmtEUR(platTotals.investi)}</td>
<td className="num">{fmtEUR(platTotals.valorisation)}</td>
<td className="num">{fmtEUR(platTotals.distributions)}</td>
<td className="num">{fmtMultiple(platTotals.tvpi)}</td>
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
<td />
</tr>
</tfoot>
)}
</table>
)}
</div>
</div>
)}
{/* ====== ONGLET VISION MENSUELLE ====== */}
{activeTab === 'vision-mensuelle' && (
<div style={{ padding: '0 24px' }}>
<ValorisationMensuelleTable rows={rows} valorisations={valorisations} plats={plats} />
</div>
)}
{/* ====== ONGLET INVESTISSEMENTS ====== */}
{activeTab === 'investissements' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>
Liste des investissements
{filteredRows.length !== sorted.length && (
<span style={{ marginLeft: 8, fontSize: 'var(--fs-xs)', fontWeight: 400, color: 'var(--text-muted)' }}>
{filteredRows.length} / {sorted.length}
</span>
)}
</h3>
<button className="primary" onClick={openAdd}>+ Nouvel investissement</button>
</div>
{/* Filtres */}
<div className="row" style={{ marginBottom: 12, gap: 8 }}>
<div style={{ flex: 1, minWidth: 130 }}>
<label>Statut</label>
<select value={filter.statut} onChange={e => setFilter(f => ({ ...f, statut: e.target.value }))}>
<option value="">Tous</option>
<option value="en_attente">En attente</option>
<option value="valide">Validé</option>
<option value="cloture">Clôturé</option>
<option value="perte_definitive">Perte définitive</option>
</select>
</div>
<div style={{ flex: 1, minWidth: 130 }}>
<label>Plateforme</label>
<select value={filter.plateforme_id} onChange={e => setFilter(f => ({ ...f, plateforme_id: e.target.value }))}>
<option value="">Toutes</option>
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
</select>
</div>
</div>
<table>
<thead>
<tr>
<th>Deal</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}
<th>Stratégie</th><th>Date</th><th className="num">Montant investi</th>
<th className="num">Dernière valorisation</th><th className="num">TVPI</th>
<th>Objectif</th><th>Statut</th><th style={{ width: 70 }}></th>
</tr>
</thead>
<tbody>
{filteredRows.length === 0 && (
<tr><td colSpan={multiDetenteur ? 11 : 10} className="text-muted" style={{ textAlign: 'center' }}>Aucun investissement</td></tr>
)}
{filteredRows.map(r => (
<tr key={r.id} className="dr-row" style={{ cursor: 'pointer' }} onClick={() => navigate(`/investissements/${r.id}`)}>
<td>{r.nom_deal}</td>
<td>{r.plateforme_nom}</td>
{multiDetenteur && <td className="text-muted">{r.investisseur_nom || '—'}</td>}
<td className="text-muted">{r.strategie || '—'}</td>
<td>{fmtDate(r.date_souscription)}</td>
<td className="num">{fmtEUR(r.montant_investi)}</td>
<td className="num">{r.derniere_valorisation != null ? fmtEUR(r.derniere_valorisation) : '—'}</td>
<td className="num">{fmtMultiple(r.tvpi)}</td>
<td className="text-muted">{r.objectif || '—'}</td>
<td className="text-muted">{STATUT_LABELS[r.statut] || r.statut}</td>
<td onClick={e => e.stopPropagation()}>
<button className="icon-btn" title="Ajouter un frais" onClick={() => openFraisAdd(r)}>€</button>
<button className="icon-btn" title="Supprimer" onClick={() => setDeleteTarget(r)}>✕</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<InvestissementPeFormModal
open={modalOpen}
editingId={editingId}
onClose={close}
form={form}
setForm={setForm}
plats={plats}
investisseurs={investisseurs}
multiDetenteur={multiDetenteur}
comptesInvestisseur={comptesInvestisseur}
err={err}
submitting={submitting}
onSubmit={() => runSubmit(submit)}
onDelete={() => { const target = { id: editingId }; close(); setDeleteTarget(target); }}
investisseurForPlat={investisseurForPlat}
/>
<ConfirmModal
open={!!deleteTarget}
title="Supprimer cet investissement ?"
message="Cette action est irréversible. Les distributions déjà saisies (Remboursements) sont conservées mais détachées de ce deal."
confirmLabel="Supprimer"
onConfirm={onDelete}
onCancel={() => setDeleteTarget(null)}
/>
{/* ── Modale annexe : "+ frais" rapide depuis une ligne de la liste ── */}
<FraisFormModal
open={!!fraisAddDeal}
onClose={closeFraisAdd}
editingId={null}
form={fraisAddForm}
onFieldChange={(k, v) => setFraisAddForm(f => ({ ...f, [k]: v }))}
err={fraisAddErr}
onSubmit={e => runFraisAddSubmit(submitFraisAdd, e)}
onDelete={null}
comptesInvestisseur={fraisAddComptes}
locked
investissementOptions={[]}
confirmingDelete={false}
setConfirmingDelete={() => {}}
submitting={fraisAddSubmitting}
/>
</>
);
}
+14 -7
View File
@@ -14,6 +14,7 @@ import { InteretsChartProvider } from '../context/InteretsChartContext.jsx';
import { fmtEUR, fmtDate, fmtStatut, memberLabel, today } from '../utils/format.js';
import { xirr } from '../utils/xirr.js';
import { usePagination } from '../hooks/usePagination.js';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import Pagination from '../components/Pagination.jsx';
import Modal from '../components/Modal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx';
@@ -435,6 +436,7 @@ export default function Plateformes() {
const [rembInv, setRembInv] = useState(null); // investissement lié au remb en cours d'édition
const [rembForm, setRembForm] = useState({});
const [rembErr, setRembErr] = useState(null);
const { run: runSubmitRemb, pending: submittingRemb } = useAsyncAction();
const [rembConfirmingDelete, setRembConfirmingDelete] = useState(false);
const [comptesRembInvestisseur, setComptesRembInvestisseur] = useState([]);
@@ -1082,6 +1084,7 @@ export default function Plateformes() {
const [mvEditingId, setMvEditingId] = useState(null);
const [mvForm, setMvForm] = useState(emptyMouvementForm);
const [mvErr, setMvErr] = useState(null);
const { run: runMvSubmit, pending: mvSubmitting } = useAsyncAction();
const [mvSelectedRow, setMvSelectedRow] = useState(null);
const [mvOpenMenu, setMvOpenMenu] = useState(null); // { row, x, y }
const [mvDeleteConfirm, setMvDeleteConfirm] = useState(null);
@@ -3100,20 +3103,23 @@ export default function Plateformes() {
open={mvModalOpen}
title="Modifier le mouvement"
onClose={mvCloseEdit}
busy={mvSubmitting}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<div style={{ display: 'flex', gap: 8 }}>
<button className="danger" onClick={mvOnDelete}>Supprimer</button>
<button onClick={() => mvOpenCorrection(null)}>Correction de solde</button>
<button className="danger" disabled={mvSubmitting} onClick={mvOnDelete}>Supprimer</button>
<button disabled={mvSubmitting} onClick={() => mvOpenCorrection(null)}>Correction de solde</button>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={mvCloseEdit}>Annuler</button>
<button className="primary" onClick={mvSubmit}>Enregistrer</button>
<button disabled={mvSubmitting} onClick={mvCloseEdit}>Annuler</button>
<button className="primary" disabled={mvSubmitting} onClick={() => runMvSubmit(mvSubmit)}>
{mvSubmitting ? 'Enregistrement…' : 'Enregistrer'}
</button>
</div>
</div>
}
>
<form onSubmit={mvSubmit}>
<form onSubmit={e => runMvSubmit(mvSubmit, e)}>
{mvErr && <div className="error">{mvErr}</div>}
<div className="form-grid">
<div>
@@ -3260,8 +3266,9 @@ export default function Plateformes() {
open={rembModalOpen}
editingId={rembEditingId}
onClose={closeRembModal}
onSubmit={submitRemb}
onDelete={onDeleteRemb}
onSubmit={e => runSubmitRemb(submitRemb, e)}
onDelete={() => runSubmitRemb(onDeleteRemb)}
submitting={submittingRemb}
confirmingDelete={rembConfirmingDelete}
setConfirmingDelete={setRembConfirmingDelete}
err={rembErr}
+5 -2
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { usePagination } from '../hooks/usePagination.js';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import Pagination from '../components/Pagination.jsx';
import PageIcon from '../components/PageIcon.jsx';
import EmptyState from '../components/EmptyState.jsx';
@@ -245,6 +246,7 @@ export default function Remboursements() {
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(empty);
const [err, setErr] = useState(null);
const { run: runSubmit, pending: submitting } = useAsyncAction();
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [corrDeleteConfirm, setCorrDeleteConfirm] = useState(null);
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
@@ -1543,8 +1545,9 @@ export default function Remboursements() {
open={modalOpen}
editingId={editingId}
onClose={close}
onSubmit={submit}
onDelete={onDelete}
onSubmit={e => runSubmit(submit, e)}
onDelete={() => runSubmit(onDelete)}
submitting={submitting}
confirmingDelete={confirmingDelete}
setConfirmingDelete={setConfirmingDelete}
err={err}
+911
View File
@@ -0,0 +1,911 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useWorkspace } from '../context/WorkspaceContext.jsx';
import Modal from '../components/Modal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx';
import EmptyState from '../components/EmptyState.jsx';
import PageIcon from '../components/PageIcon.jsx';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import { usePagination } from '../hooks/usePagination.js';
import Pagination from '../components/Pagination.jsx';
import InteretsChart from '../components/InteretsChart.jsx';
import InteretsDistributionChart from '../components/InteretsDistributionChart.jsx';
import * as XLSX from 'xlsx';
import { fmtEUR, fmtDate, today } from '../utils/format.js';
const TYPE_LABELS = {
bonus_plateforme: 'Bonus plateforme',
bonus_parrainage: 'Bonus parrainage',
interets_plateforme: 'Intérêts plateforme',
};
const MOIS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
const MOIS_ABBR = ['Jan','Fév','Mar','Avr','Mai','Juin','Juil','Août','Sep','Oct','Nov','Déc'];
/* ── Export (Excel/CSV/JSON) — même patron que Remboursements.jsx (crowdlending,
* chantier "remboursements PE", 17/09/26, à la demande d'Olivier), colonnes
* adaptées au modèle PE (pas de capital, lien vers un deal optionnel). ── */
function dlBlob(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
function rembPeRow(r, dealNameById) {
return {
date_remb: r.date_remb, plateforme_nom: r.plateforme_nom || '',
detenteur_nom: r.plateforme_detenteur_nom || '',
type: TYPE_LABELS[r.type] || r.type,
deal: r.investissement_pe_id ? (dealNameById[r.investissement_pe_id] || '') : '',
cashback: r.cashback || 0, interets_bruts: r.interets_bruts || 0, frais_ttc: r.frais_ttc || 0,
prelev_sociaux: r.prelev_sociaux || 0, prelev_forfaitaire: r.prelev_forfaitaire || 0,
interets_nets: r.interets_nets || 0, net_recu: r.net_recu || 0,
statut: r.statut,
};
}
function rembPeToCSV(rows, dealNameById) {
const BOM = '\ufeff'; const sep = ';';
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
const headers = ['Date','Plateforme','Détenteur','Type','Investissement','Cashback (€)','Intérêts bruts (€)','Frais TTC (€)','Prélèv. sociaux (€)','Prélèvement forfaitaire (€)','Intérêts nets (€)','Net reçu (€)','Statut'];
const data = rows.map(r => {
const d = rembPeRow(r, dealNameById);
return [
d.date_remb, d.plateforme_nom, d.detenteur_nom, d.type, d.deal,
String(d.cashback).replace('.', ','), String(d.interets_bruts).replace('.', ','), String(d.frais_ttc).replace('.', ','),
String(d.prelev_sociaux).replace('.', ','), String(d.prelev_forfaitaire).replace('.', ','),
String(d.interets_nets).replace('.', ','), String(d.net_recu).replace('.', ','), d.statut,
];
});
return BOM + [headers, ...data].map(r => r.map(q).join(sep)).join('\r\n');
}
function rembPeToXLS(rows, dealNameById) {
const data = rows.map(r => {
const d = rembPeRow(r, dealNameById);
return {
'Date': d.date_remb, 'Plateforme': d.plateforme_nom, 'Détenteur': d.detenteur_nom, 'Type': d.type,
'Investissement': d.deal, 'Cashback (€)': d.cashback, 'Intérêts bruts (€)': d.interets_bruts,
'Frais TTC (€)': d.frais_ttc, 'Prélèv. sociaux (€)': d.prelev_sociaux, 'Prélèvement forfaitaire (€)': d.prelev_forfaitaire,
'Intérêts nets (€)': d.interets_nets, 'Net reçu (€)': d.net_recu, 'Statut': d.statut,
};
});
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Remboursements');
return XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
}
function rembPeToJSON(rows, dealNameById) {
return JSON.stringify(rows.map(r => rembPeRow(r, dealNameById)), null, 2);
}
/* ── Menu déroulant d'export (CSV/Excel/JSON) — identique au composant de
* Remboursements.jsx : rien ici n'est spécifique crowdlending. ── */
function ExportDropdown({ disabled, onCSV, onXLS, onJSON }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
const choose = fn => { setOpen(false); fn(); };
return (
<div ref={ref} style={{ position: 'relative' }}>
<button type="button" className="icon-btn" disabled={disabled} onClick={() => setOpen(o => !o)} title="Exporter">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
{open && (
<div className="export-dropdown" role="menu">
<button role="menuitem" onClick={() => choose(onCSV)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
<line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/>
</svg>
<span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
</button>
<button role="menuitem" onClick={() => choose(onXLS)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l2 2 4-4"/>
</svg>
<span><strong>Format Excel</strong><small>Fichier .xlsx Microsoft Excel</small></span>
</button>
<button role="menuitem" onClick={() => choose(onJSON)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
<path d="M8 13h1.5a1 1 0 0 1 1 1v1a1 1 0 0 0 1 1 1 1 0 0 0-1 1v1a1 1 0 0 1-1 1H8"/>
<path d="M16 13h-1.5a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1H16"/>
</svg>
<span><strong>Format JSON</strong><small>Réimportable, structuré</small></span>
</button>
</div>
)}
</div>
);
}
const emptyRembForm = {
type: 'bonus_plateforme',
bonus_plateforme_id: '',
bonus_investisseur_id: '',
// Lien optionnel vers un deal PE (investissements_pe, 17/09/26) : permet de
// rattacher une distribution à un investissement précis pour calculer son
// TVPI, quand plusieurs deals partagent une même plateforme (ex. Fundora).
investissement_pe_id: '',
date_remb: today(),
statut: 'paye',
notes: '',
// Bonus (parrainage / plateforme)
cashback: '',
// Intérêts plateforme
interets_bruts_avant_local: '',
taxe_locale: '',
interets_bruts: '',
frais_ttc: '',
prelev_sociaux: '',
prelev_forfaitaire: '',
};
/**
* Remboursements — version simplifiée, utilisée par tout workspace autre que
* crowdlending (Private Equity pour l'instant — cf. RemboursementsRouter dans
* App.jsx, jamais lié en dur à un slug précis), même patron que
* DepotsRetraitsSimple.jsx.
*
* Un remboursement "normal" (type par défaut de pages/Remboursements.jsx) est
* obligatoirement rattaché à un investissement (investissement_id), or la
* table investissements n'a pas encore de workspace_id et sa page reste
* réservée à crowdlending (cf. project_workspaces_transformation.md, Étape 2
* à venir table par table). Cette page se limite donc, à la demande
* d'Olivier (17/09/26), aux 3 types de remboursement qui ne dépendent que
* d'une plateforme + d'un détenteur : bonus parrainage, bonus plateforme,
* intérêts plateforme — déjà pleinement utilisables puisque plateformes
* supporte le multi-workspace (N-N) depuis une étape précédente.
*
* L'endpoint (/remboursements) est directement partagé avec la page
* crowdlending, déjà flêché par workspace côté serveur via l'en-tête
* X-Workspace-Id (cf. workspaceScope.js, Étape 2a/2c du 17/09/26).
*/
export default function RemboursementsSimple() {
const { activeId: investisseurId, investisseurs } = useInvestisseur();
const { active: activeWorkspace } = useWorkspace() || {};
const location = useLocation();
const navigate = useNavigate();
const [rows, setRows] = useState([]);
const [allPlats, setAllPlats] = useState([]);
const [deals, setDeals] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(emptyRembForm);
const [deleteTarget, setDeleteTarget] = useState(null);
const { run: runSubmit, pending: submitting } = useAsyncAction();
// Plateformes éligibles au workspace actif uniquement (GET /plateformes
// n'est pas filtré côté serveur — seulement enrichi de row.workspaces),
// même patron que DepotsRetraitsSimple.
const plats = useMemo(
() => allPlats.filter(p => (p.workspaces || []).some(w => w.id === activeWorkspace?.id)),
[allPlats, activeWorkspace],
);
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
// ── Onglets + filtres (chantier "remboursements PE", 17/09/26 — rapprochement de
// la page Remboursements crowdlending, à la demande d'Olivier) : Plateformes
// (graphiques + répartition), Vision mensuelle, Remboursements (liste filtrable,
// paginée, exportable). Pas d'onglets "Mois en cours"/"Projections" : ils reposent
// sur un échéancier prévisionnel qui n'existe pas pour un deal PE.
const [activeTab, setActiveTab] = useState('plateformes');
const [rembPlatYear, setRembPlatYear] = useState('');
const [vmYear, setVmYear] = useState('');
const [tableFilter, setTableFilter] = useState({ plateforme_id: '', type: '', investissement_pe_id: '', year: '', month: '' });
const load = async () => {
setLoading(true);
try {
const [rb, pl, dl] = await Promise.all([
api.get('/remboursements'),
api.get('/plateformes'),
api.get('/investissements-pe'),
]);
setRows(rb);
setAllPlats(pl);
setDeals(dl);
} catch (e) {
setErr(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (investisseurId) load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [investisseurId, activeWorkspace?.id]);
const investisseurForPlat = (platId) => {
const plat = plats.find(p => String(p.id) === String(platId));
return plat?.investisseur_id ? String(plat.investisseur_id) : String(investisseurId || '');
};
const openAdd = () => {
setEditingId(null);
const firstId = plats[0]?.id || '';
setForm({ ...emptyRembForm, bonus_plateforme_id: firstId, bonus_investisseur_id: investisseurForPlat(firstId) });
setErr(null);
setModalOpen(true);
};
// Ouverture auto depuis le bouton global "Nouveau remboursement" (topbar/burger,
// cf. Layout.jsx — même ?new=1 que la page crowdlending et que Dépôts/Retraits).
const pendingNew = useRef(false);
useEffect(() => {
const params = new URLSearchParams(location.search);
if (params.get('new') !== '1') return;
navigate('/remboursements', { replace: true });
if (plats.length > 0) {
openAdd();
} else {
pendingNew.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.search]);
useEffect(() => {
if (pendingNew.current && plats.length > 0) {
pendingNew.current = false;
openAdd();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [plats.length]);
// Le type n'est pas modifiable en édition : le backend choisit sa branche
// d'UPDATE (et donc les colonnes réellement écrites) d'après form.type
// soumis, pas d'après le type déjà stocké en base — changer de type en
// cours d'édition désynchroniserait la ligne. On fige donc le type choisi
// à la création.
const openEdit = (r) => {
setEditingId(r.id);
setForm({
type: r.type,
bonus_plateforme_id: String(r.bonus_plateforme_id || ''),
bonus_investisseur_id: String(r.bonus_investisseur_id || ''),
investissement_pe_id: r.investissement_pe_id ? String(r.investissement_pe_id) : '',
date_remb: r.date_remb,
statut: r.statut,
notes: r.notes || '',
cashback: String(r.cashback ?? ''),
interets_bruts_avant_local: String(r.interets_bruts_avant_local ?? ''),
taxe_locale: String(r.taxe_locale ?? ''),
interets_bruts: String(r.interets_bruts ?? ''),
frais_ttc: String(r.frais_ttc ?? ''),
prelev_sociaux: String(r.prelev_sociaux ?? ''),
prelev_forfaitaire: String(r.prelev_forfaitaire ?? ''),
});
setErr(null);
setModalOpen(true);
};
const close = () => { setModalOpen(false); setEditingId(null); setForm(emptyRembForm); setErr(null); };
const submit = async (e) => {
e?.preventDefault?.(); setErr(null);
const base = {
type: form.type,
bonus_plateforme_id: Number(form.bonus_plateforme_id),
bonus_investisseur_id: Number(form.bonus_investisseur_id) || undefined,
investissement_pe_id: Number(form.investissement_pe_id) || undefined,
date_remb: form.date_remb,
statut: form.statut,
notes: form.notes || undefined,
};
const payload = form.type === 'interets_plateforme'
? {
...base,
interets_bruts_avant_local: Number(form.interets_bruts_avant_local) || 0,
taxe_locale: Number(form.taxe_locale) || 0,
interets_bruts: Number(form.interets_bruts) || 0,
frais_ttc: Number(form.frais_ttc) || 0,
prelev_sociaux: Number(form.prelev_sociaux) || 0,
prelev_forfaitaire: Number(form.prelev_forfaitaire) || 0,
}
: {
...base,
cashback: Number(form.cashback) || 0,
};
try {
if (editingId) await api.put(`/remboursements/${editingId}`, payload);
else await api.post('/remboursements', payload);
close();
await load();
} catch (e) { setErr(e.message); }
};
const onDelete = async () => {
if (!deleteTarget) return;
try {
await api.del(`/remboursements/${deleteTarget.id}`);
setDeleteTarget(null);
await load();
} catch (e) { setErr(e.message); }
};
const sorted = useMemo(
() => [...rows].sort((a, b) => b.date_remb.localeCompare(a.date_remb) || b.id - a.id),
[rows],
);
const statutLabel = (s) => ({ paye: 'Payé', retard: 'En retard', partiel: 'Partiel', impaye: 'Impayé' }[s] || s);
const years = useMemo(
() => [...new Set(rows.map(r => r.date_remb.slice(0, 4)))].sort().reverse(),
[rows],
);
const dealNameById = useMemo(() => {
const map = {};
for (const d of deals) map[d.id] = d.nom_deal;
return map;
}, [deals]);
/* ── Onglet Plateformes : graphiques + répartition ──────────────────────
* InteretsChart/InteretsDistributionChart (crowdlending) ne lisent que
* date_remb + interets_nets/interets_bruts — génériques, réutilisés tels
* quels. Le modèle PE n'a pas de bascule brut/net au niveau agrégat (pas
* de fiscalité à la source recalculée ici) : on aligne les deux champs sur
* net_recu (cashback + intérêts nets déjà calculés côté backend) pour que
* la courbe/le treemap représentent le total réellement perçu. */
const netMode = true;
const chartRows = useMemo(() => {
const src = rembPlatYear ? rows.filter(r => r.date_remb.slice(0, 4) === rembPlatYear) : rows;
return src.map(r => ({ ...r, interets_nets: r.net_recu, interets_bruts: r.net_recu }));
}, [rows, rembPlatYear]);
const byPlatform = useMemo(() => {
const src = rembPlatYear ? rows.filter(r => r.date_remb.slice(0, 4) === rembPlatYear) : rows;
const map = {};
for (const r of src) {
const key = String(r.plateforme_id);
if (!map[key]) map[key] = {
plateforme_id: r.plateforme_id, nom: r.plateforme_nom,
detenteur_nom: r.plateforme_detenteur_nom || null,
cashback: 0, interets_nets: 0, net_recu: 0, count: 0,
};
map[key].cashback += r.cashback || 0;
map[key].interets_nets += r.interets_nets || 0;
map[key].net_recu += r.net_recu || 0;
map[key].count++;
}
return Object.values(map).sort((a, b) => b.net_recu - a.net_recu);
}, [rows, rembPlatYear]);
const byPlatformTotals = useMemo(() => byPlatform.reduce((acc, p) => {
acc.cashback += p.cashback; acc.interets_nets += p.interets_nets;
acc.net_recu += p.net_recu; acc.count += p.count;
return acc;
}, { cashback: 0, interets_nets: 0, net_recu: 0, count: 0 }), [byPlatform]);
const totalNetRecu = byPlatformTotals.net_recu;
/* ── Onglet Vision mensuelle : grille Plateforme × mois, année sélectionnée.
* Pas de composant réutilisable côté crowdlending (TableauInteretsPlateforme
* dépend d'un endpoint + contexte bâtis autour d'un échéancier réel/projeté
* qui n'existe pas pour un deal PE) — grille reconstruite ici, client-side,
* à partir des lignes déjà chargées. ── */
const effectiveVmYear = vmYear || years[0] || String(new Date().getFullYear());
const vmData = useMemo(() => {
const map = {};
for (const r of rows) {
if (r.date_remb.slice(0, 4) !== effectiveVmYear) continue;
const key = String(r.plateforme_id);
if (!map[key]) map[key] = { plateforme_id: r.plateforme_id, nom: r.plateforme_nom, months: Array(12).fill(0), total: 0 };
const m = Number(r.date_remb.slice(5, 7)) - 1;
map[key].months[m] += r.net_recu || 0;
map[key].total += r.net_recu || 0;
}
return Object.values(map).sort((a, b) => b.total - a.total);
}, [rows, effectiveVmYear]);
const vmMonthTotals = useMemo(() => {
const totals = Array(12).fill(0);
for (const p of vmData) for (let i = 0; i < 12; i++) totals[i] += p.months[i];
return totals;
}, [vmData]);
const vmGrandTotal = vmMonthTotals.reduce((s, v) => s + v, 0);
/* ── Onglet Remboursements : liste filtrable, paginée, exportable ── */
const tableRows = useMemo(() => sorted.filter(r => {
if (tableFilter.plateforme_id && String(r.plateforme_id) !== tableFilter.plateforme_id) return false;
if (tableFilter.type && r.type !== tableFilter.type) return false;
if (tableFilter.investissement_pe_id && String(r.investissement_pe_id || '') !== tableFilter.investissement_pe_id) return false;
if (tableFilter.year && r.date_remb.slice(0, 4) !== tableFilter.year) return false;
if (tableFilter.month && r.date_remb.slice(5, 7) !== tableFilter.month) return false;
return true;
}), [sorted, tableFilter]);
const {
pagedItems: pagedTableRows, page: rembPage, setPage: setRembPage,
pageSize: rembPageSize, setPageSize: setRembPageSize,
totalPages: rembTotalPages, totalItems: rembTotalItems, PAGE_SIZES,
} = usePagination(tableRows, 'cl_pagesize_remb_pe', [tableFilter]);
if (!loading && plats.length === 0) return (
<>
<div className="topbar"><h2><PageIcon name="remboursement" />Remboursements</h2></div>
<EmptyState
title="Aucune plateforme dans cet espace de travail"
message={`Ajoutez d'abord une plateforme à ${activeWorkspace?.libelle_menu || activeWorkspace?.nom || 'ce workspace'} depuis Mes plateformes pour pouvoir y enregistrer des remboursements.`}
cta="Configurer mes plateformes"
to="/settings?section=plateformes&openAdd=1"
/>
</>
);
if (!loading && !modalOpen && sorted.length === 0) return (
<>
<div className="topbar"><h2><PageIcon name="remboursement" />Remboursements</h2></div>
<EmptyState
icon="💰"
title="Aucun remboursement enregistré"
message="Vos plateformes sont configurées, mais aucun remboursement n'a encore été enregistré."
cta="Ajouter un remboursement"
to="/remboursements?new=1"
/>
<Modal
open={modalOpen}
title={editingId ? 'Modifier le remboursement' : 'Nouveau remboursement'}
onClose={close}
busy={submitting}
footer={
<div style={{ display: 'flex', gap: 8 }}>
<button disabled={submitting} onClick={close}>Annuler</button>
<button className="primary" disabled={submitting} onClick={() => runSubmit(submit)}>
{submitting ? 'Création…' : 'Créer'}
</button>
</div>
}
>
<RemboursementForm form={form} setForm={setForm} plats={plats} investisseurs={investisseurs} deals={deals}
multiDetenteur={multiDetenteur} err={err} runSubmit={runSubmit} submit={submit}
investisseurForPlat={investisseurForPlat} editingId={editingId} />
</Modal>
</>
);
return (
<>
<div className="topbar">
<h2><PageIcon name="remboursement" />Remboursements</h2>
<button className="primary" onClick={openAdd}>+ Nouveau remboursement</button>
</div>
{err && <div className="error" style={{ margin: '0 0 16px' }}>{err}</div>}
<div className="charts-row">
<InteretsChart rows={chartRows} netMode={netMode} />
<InteretsDistributionChart rows={chartRows} netMode={netMode} />
</div>
<div className="dr-tabs">
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
onClick={() => setActiveTab('plateformes')}>Plateformes</button>
<button className={`dr-tab${activeTab === 'vision-mensuelle' ? ' active' : ''}`}
onClick={() => setActiveTab('vision-mensuelle')}>Vision mensuelle</button>
<button className={`dr-tab${activeTab === 'remboursements' ? ' active' : ''}`}
onClick={() => setActiveTab('remboursements')}>Remboursements</button>
</div>
{/* ══ ONGLET PLATEFORMES ══ */}
{activeTab === 'plateformes' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>
Répartition par plateforme
{rembPlatYear && <span style={{ marginLeft: 8 }}>pour l'année {rembPlatYear}</span>}
</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<select value={rembPlatYear} onChange={e => setRembPlatYear(e.target.value)}
style={{ fontSize: 'var(--fs-xs)', padding: '4px 8px', height: 30, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'pointer', outline: 'none' }}>
<option value="">Toutes les années</option>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
<ExportDropdown
disabled={byPlatform.length === 0}
onCSV={() => dlBlob(rembPeToCSV(rows, dealNameById), 'remboursements.csv', 'text/csv;charset=utf-8')}
onXLS={() => dlBlob(rembPeToXLS(rows, dealNameById), 'remboursements.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
onJSON={() => dlBlob(rembPeToJSON(rows, dealNameById), 'remboursements.json', 'application/json')}
/>
</div>
</div>
<table>
<thead>
<tr>
<th>Plateforme</th>
{multiDetenteur && <th>Détenteur</th>}
<th className="num">Cashback</th>
<th className="num">Intérêts (nets)</th>
<th className="num">Total net perçu</th>
<th className="num">Mouvements</th>
<th className="num" style={{ minWidth: 120 }}>Poids</th>
</tr>
</thead>
<tbody>
{byPlatform.length === 0 && (
<tr><td colSpan={multiDetenteur ? 7 : 6} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun remboursement'}
</td></tr>
)}
{byPlatform.map(p => {
const poids = totalNetRecu !== 0 ? (p.net_recu / totalNetRecu) * 100 : 0;
return (
<tr key={p.plateforme_id} className="dr-row" style={{ cursor: 'pointer' }}
onClick={() => { setTableFilter(f => ({ ...f, plateforme_id: String(p.plateforme_id) })); setActiveTab('remboursements'); }}
title="Cliquer pour voir le détail dans l'onglet Remboursements"
>
<td>{p.nom}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>}
<td className="num">{p.cashback > 0 ? fmtEUR(p.cashback) : '—'}</td>
<td className="num">{p.interets_nets > 0 ? fmtEUR(p.interets_nets) : '—'}</td>
<td className="num">{fmtEUR(p.net_recu)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{p.count}</td>
<td className="num">
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
</div>
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>{poids.toFixed(1)} %</span>
</div>
</td>
</tr>
);
})}
</tbody>
{byPlatform.length > 1 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td>Total — {byPlatform.length} plateformes</td>
{multiDetenteur && <td />}
<td className="num">{byPlatformTotals.cashback > 0 ? fmtEUR(byPlatformTotals.cashback) : '—'}</td>
<td className="num">{byPlatformTotals.interets_nets > 0 ? fmtEUR(byPlatformTotals.interets_nets) : '—'}</td>
<td className="num">{fmtEUR(byPlatformTotals.net_recu)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{byPlatformTotals.count}</td>
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
)}
{/* ══ ONGLET VISION MENSUELLE ══ */}
{activeTab === 'vision-mensuelle' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Vision mensuelle — {effectiveVmYear}</h3>
<select value={effectiveVmYear} onChange={e => setVmYear(e.target.value)}
style={{ fontSize: 'var(--fs-xs)', padding: '4px 8px', height: 30, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'pointer', outline: 'none' }}>
{(years.length ? years : [effectiveVmYear]).map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
<div style={{ overflowX: 'auto' }}>
<table>
<thead>
<tr>
<th>Plateforme</th>
{MOIS_ABBR.map(m => <th key={m} className="num">{m}</th>)}
<th className="num">Total</th>
</tr>
</thead>
<tbody>
{vmData.length === 0 && (
<tr><td colSpan={14} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : `Aucun remboursement en ${effectiveVmYear}`}
</td></tr>
)}
{vmData.map(p => (
<tr key={p.plateforme_id}>
<td>{p.nom}</td>
{p.months.map((v, i) => <td key={i} className="num">{v > 0 ? fmtEUR(v) : '—'}</td>)}
<td className="num" style={{ fontWeight: 600 }}>{fmtEUR(p.total)}</td>
</tr>
))}
</tbody>
{vmData.length > 1 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td>Total</td>
{vmMonthTotals.map((v, i) => <td key={i} className="num">{v > 0 ? fmtEUR(v) : '—'}</td>)}
<td className="num">{fmtEUR(vmGrandTotal)}</td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
</div>
)}
{/* ══ ONGLET REMBOURSEMENTS ══ */}
{activeTab === 'remboursements' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Remboursements</h3>
<ExportDropdown
disabled={tableRows.length === 0}
onCSV={() => dlBlob(rembPeToCSV(tableRows, dealNameById), 'remboursements.csv', 'text/csv;charset=utf-8')}
onXLS={() => dlBlob(rembPeToXLS(tableRows, dealNameById), 'remboursements.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
onJSON={() => dlBlob(rembPeToJSON(tableRows, dealNameById), 'remboursements.json', 'application/json')}
/>
</div>
<div className="row" style={{ marginBottom: 12 }}>
<div>
<label>Plateforme</label>
<select value={tableFilter.plateforme_id} onChange={e => setTableFilter({ ...tableFilter, plateforme_id: e.target.value })}>
<option value="">Toutes</option>
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
</select>
</div>
<div>
<label>Type</label>
<select value={tableFilter.type} onChange={e => setTableFilter({ ...tableFilter, type: e.target.value })}>
<option value="">Tous</option>
<option value="bonus_plateforme">Bonus plateforme</option>
<option value="bonus_parrainage">Bonus parrainage</option>
<option value="interets_plateforme">Intérêts plateforme</option>
</select>
</div>
<div>
<label>Investissement</label>
<select value={tableFilter.investissement_pe_id} onChange={e => setTableFilter({ ...tableFilter, investissement_pe_id: e.target.value })}>
<option value="">Tous</option>
{deals.map(d => <option key={d.id} value={d.id}>{d.nom_deal}</option>)}
</select>
</div>
<div>
<label>Année</label>
<select value={tableFilter.year} onChange={e => setTableFilter({ ...tableFilter, year: e.target.value })}>
<option value="">Toutes</option>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
<div>
<label>Mois</label>
<select value={tableFilter.month} onChange={e => setTableFilter({ ...tableFilter, month: e.target.value })}>
<option value="">Tous</option>
{MOIS_FR.map((m, i) => <option key={i + 1} value={String(i + 1).padStart(2, '0')}>{m}</option>)}
</select>
</div>
</div>
<table>
<thead>
<tr>
<th>Date</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}
<th>Type</th><th className="num">Net perçu</th><th>Statut</th><th style={{ width: 40 }}></th>
</tr>
</thead>
<tbody>
{pagedTableRows.length === 0 && (
<tr><td colSpan={multiDetenteur ? 7 : 6} className="text-muted" style={{ textAlign: 'center' }}>Aucun mouvement</td></tr>
)}
{pagedTableRows.map(r => (
<tr key={r.id} className="dr-row" style={{ cursor: 'pointer' }} onClick={() => openEdit(r)}>
<td>{fmtDate(r.date_remb)}</td>
<td>{r.plateforme_nom}</td>
{multiDetenteur && <td className="text-muted">{r.plateforme_detenteur_nom || '—'}</td>}
<td>{TYPE_LABELS[r.type] || r.type}</td>
<td className="num">{fmtEUR(r.net_recu)}</td>
<td className="text-muted">{statutLabel(r.statut)}</td>
<td onClick={e => e.stopPropagation()}>
<button className="icon-btn" title="Supprimer" onClick={() => setDeleteTarget(r)}>✕</button>
</td>
</tr>
))}
</tbody>
</table>
<Pagination page={rembPage} setPage={setRembPage} pageSize={rembPageSize} setPageSize={setRembPageSize}
totalPages={rembTotalPages} totalItems={rembTotalItems} PAGE_SIZES={PAGE_SIZES} />
</div>
</div>
)}
<Modal
open={modalOpen}
title={editingId ? 'Modifier le remboursement' : 'Nouveau remboursement'}
onClose={close}
busy={submitting}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<div>
{editingId && (
<button className="danger" disabled={submitting}
onClick={() => { const target = { id: editingId }; close(); setDeleteTarget(target); }}>
Supprimer
</button>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button disabled={submitting} onClick={close}>Annuler</button>
<button className="primary" disabled={submitting} onClick={() => runSubmit(submit)}>
{submitting ? (editingId ? 'Enregistrement…' : 'Création…') : (editingId ? 'Enregistrer' : 'Créer')}
</button>
</div>
</div>
}
>
<RemboursementForm form={form} setForm={setForm} plats={plats} investisseurs={investisseurs} deals={deals}
multiDetenteur={multiDetenteur} err={err} runSubmit={runSubmit} submit={submit}
investisseurForPlat={investisseurForPlat} editingId={editingId} />
</Modal>
<ConfirmModal
open={!!deleteTarget}
title="Supprimer ce remboursement ?"
message="Cette action est irréversible."
confirmLabel="Supprimer"
onConfirm={onDelete}
onCancel={() => setDeleteTarget(null)}
/>
</>
);
}
/** Formulaire d'ajout/édition — factorisé pour être identique dans les deux
* points d'entrée (page avec mouvements existants, et état vide). Champs
* strictement alignés sur le Schema de backend/src/routes/remboursements.js,
* limités aux types bonus_parrainage / bonus_plateforme / interets_plateforme
* (le type "normal", lié à un investissement, reste crowdlending uniquement). */
function RemboursementForm({ form, setForm, plats, investisseurs, deals, multiDetenteur, err, runSubmit, submit, investisseurForPlat, editingId }) {
const isInterets = form.type === 'interets_plateforme';
// Deals PE de la plateforme sélectionnée (un remboursement bonus/intérêts
// peut être rattaché à un deal précis, pour le calcul de son TVPI — cf.
// pages/InvestissementsPe.jsx). Optionnel : plusieurs plateformes n'ont
// qu'un seul deal, ou aucun deal PE suivi pour l'instant.
const dealsForPlat = deals.filter(d => String(d.plateforme_id) === String(form.bonus_plateforme_id));
const previewNet = () => {
if (isInterets) {
const bruts = Number(form.interets_bruts) || 0;
const frais = Number(form.frais_ttc) || 0;
const ps = Number(form.prelev_sociaux) || 0;
const pf = Number(form.prelev_forfaitaire) || 0;
return Math.round((bruts - frais - ps - pf) * 100) / 100;
}
return Number(form.cashback) || 0;
};
return (
<form onSubmit={e => runSubmit(submit, e)}>
{err && <div className="error">{err}</div>}
<div className="form-grid">
<div>
<label>Type *</label>
<select required value={form.type} disabled={!!editingId}
title={editingId ? 'Le type ne peut pas être modifié après création' : undefined}
onChange={e => setForm({ ...form, type: e.target.value })}>
<option value="bonus_plateforme">Bonus plateforme</option>
<option value="bonus_parrainage">Bonus parrainage</option>
<option value="interets_plateforme">Intérêts plateforme</option>
</select>
</div>
<div>
<label>Plateforme *</label>
<select required value={form.bonus_plateforme_id} onChange={e => {
const platId = e.target.value;
setForm(f => ({ ...f, bonus_plateforme_id: platId, bonus_investisseur_id: investisseurForPlat(platId), investissement_pe_id: '' }));
}}>
<option value="">—</option>
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
</select>
</div>
{dealsForPlat.length > 0 && (
<div>
<label>Investissement (optionnel)</label>
<select value={form.investissement_pe_id} onChange={e => setForm({ ...form, investissement_pe_id: e.target.value })}>
<option value="">— Aucun (non rattaché à un deal) —</option>
{dealsForPlat.map(d => <option key={d.id} value={d.id}>{d.nom_deal}</option>)}
</select>
</div>
)}
<div>
<label>Date *</label>
<input type="date" required value={form.date_remb} onChange={e => setForm({ ...form, date_remb: e.target.value })} />
</div>
{!isInterets && (
<div>
<label>Montant reçu (€) *</label>
<input type="number" step="0.01" min="0" required value={form.cashback} onChange={e => setForm({ ...form, cashback: e.target.value })} />
</div>
)}
{isInterets && (
<>
<div>
<label>Intérêts bruts (€) *</label>
<input type="number" step="0.01" min="0" required value={form.interets_bruts} onChange={e => setForm({ ...form, interets_bruts: e.target.value })} />
</div>
<div>
<label>Intérêts bruts avant taxe locale (€)</label>
<input type="number" step="0.01" min="0" value={form.interets_bruts_avant_local} onChange={e => setForm({ ...form, interets_bruts_avant_local: e.target.value })} />
</div>
<div>
<label>Taxe locale (€)</label>
<input type="number" step="0.01" min="0" value={form.taxe_locale} onChange={e => setForm({ ...form, taxe_locale: e.target.value })} />
</div>
<div>
<label>Frais TTC (€)</label>
<input type="number" step="0.01" min="0" value={form.frais_ttc} onChange={e => setForm({ ...form, frais_ttc: e.target.value })} />
</div>
<div>
<label>Prélèvements sociaux (€)</label>
<input type="number" step="0.01" min="0" value={form.prelev_sociaux} onChange={e => setForm({ ...form, prelev_sociaux: e.target.value })} />
</div>
<div>
<label>Prélèvement forfaitaire (€)</label>
<input type="number" step="0.01" min="0" value={form.prelev_forfaitaire} onChange={e => setForm({ ...form, prelev_forfaitaire: e.target.value })} />
</div>
</>
)}
<div>
<label>Statut</label>
<select value={form.statut} onChange={e => setForm({ ...form, statut: e.target.value })}>
<option value="paye">Payé</option>
<option value="retard">En retard</option>
<option value="partiel">Partiel</option>
<option value="impaye">Impayé</option>
</select>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Détenteur *</label>
<select required value={form.bonus_investisseur_id} onChange={e => setForm({ ...form, bonus_investisseur_id: e.target.value })}>
<option value="">—</option>
{investisseurs.filter(i => i.type !== 'entreprise').length > 0 && (
<optgroup label="Famille">
{investisseurs.filter(i => i.type !== 'entreprise').map(i => (
<option key={i.id} value={i.id}>{i.nom}{i.is_principal ? ' (principal)' : ''}</option>
))}
</optgroup>
)}
{investisseurs.filter(i => i.type === 'entreprise').length > 0 && (
<optgroup label="Entreprises">
{investisseurs.filter(i => i.type === 'entreprise').map(i => (
<option key={i.id} value={i.id}>{i.nom}</option>
))}
</optgroup>
)}
</select>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Notes</label>
<input value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />
</div>
<div style={{ gridColumn: '1 / -1', color: 'var(--text-muted, #6b7280)', fontSize: 13 }}>
Net perçu estimé : {fmtEUR(previewNet())}
</div>
</div>
</form>
);
}
@@ -4,6 +4,7 @@ import { useUi } from '../../context/UiContext.jsx';
import { api } from '../../api.js';
import { getSuggestedPalettes } from '../../utils/chartPalettes.js';
import { COLOR_THEMES, colorThemeSwatch } from '../../utils/colorThemes.js';
import { useWorkspace } from '../../context/WorkspaceContext.jsx';
import ThemeSelect from '../../components/ThemeSelect.jsx';
@@ -281,6 +282,7 @@ function ChartColorPicker({ value, onChange }) {
export default function AppearanceSection() {
const { mode, setMode } = useTheme();
const { fontScale, setFontScale, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback, colorTheme, adminColorTheme, setColorTheme } = useUi();
const { activeWorkspaces, hasMultipleWorkspaces, setWorkspaceColor } = useWorkspace() || {};
const [libIcons, setLibIcons] = useState({});
const suggestedPalettes = useMemo(() => getSuggestedPalettes(), []);
// "Automatique" (value '') suit le thème par défaut de l'administrateur —
@@ -335,6 +337,40 @@ export default function AppearanceSection() {
/>
</div>
</div>
{/* Couleur par espace de travail (17/09/26) : visible seulement si
plusieurs espaces sont actifs (rien à distinguer sinon). Par
défaut chaque espace hérite de la couleur de l'application
ci-dessus ; l'utilisateur peut la personnaliser par espace, et
elle s'applique automatiquement en basculant dessus (cf.
Layout.jsx / UiContext.jsx workspaceColorOverride). */}
{hasMultipleWorkspaces && (
<div style={{ marginTop: 20, paddingTop: 20, borderTop: '1px solid var(--border)' }}>
<div style={{ fontWeight: 700, fontSize: 'var(--fs-sm)' }}>Couleur des espaces de travail</div>
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 2, marginBottom: 14, maxWidth: 420 }}>
Attribuez une couleur à chaque espace : elle remplace automatiquement la couleur
de l'application quand vous basculez dessus. Par défaut, un espace hérite de la
couleur de l'application ci-dessus.
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{activeWorkspaces.map((w) => (
<div key={w.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
<span style={{ fontSize: 'var(--fs-sm)' }}>{w.libelle_menu || w.nom}</span>
<div style={{ width: 260, flexShrink: 0 }}>
<ThemeSelect
value={w.color_theme || ''}
onChange={(v) => setWorkspaceColor(w.id, v || null)}
options={[
{ value: '', label: "Hérite de la couleur de l'application", swatch: colorThemeSwatch(colorTheme || adminColorTheme) },
...COLOR_THEMES,
]}
/>
</div>
</div>
))}
</div>
</div>
)}
</div>
<div className="card">
+12 -7
View File
@@ -3,6 +3,7 @@ import { api } from '../../api.js';
import { memberLabel } from '../../utils/format.js';
import ConfirmModal from '../../components/ConfirmModal.jsx';
import Modal from '../../components/Modal.jsx';
import { useAsyncAction } from '../../hooks/useAsyncAction.js';
const EXONERATION_LABELS = {
aucune: 'Aucune',
@@ -85,6 +86,8 @@ export default function ComptesSection() {
const [editCompte, setEditCompte] = useState(null);
const [err, setErr] = useState(null);
const [confirmDel, setConfirmDel] = useState(null);
const { run: runAddCompte, pending: addingCompte } = useAsyncAction();
const { run: runSaveEdit, pending: savingEdit } = useAsyncAction();
const openNew = () => { setNewCompte(EMPTY_COMPTE); setErr(null); setShowNew(true); };
@@ -204,14 +207,15 @@ export default function ComptesSection() {
open={showNew}
title="Nouveau compte"
onClose={() => { setShowNew(false); setNewCompte(EMPTY_COMPTE); setErr(null); }}
busy={addingCompte}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%', gap: 8 }}>
<button type="button" onClick={() => { setShowNew(false); setNewCompte(EMPTY_COMPTE); setErr(null); }}>Annuler</button>
<button className="primary" form="form-new-compte" type="submit">Créer</button>
<button type="button" disabled={addingCompte} onClick={() => { setShowNew(false); setNewCompte(EMPTY_COMPTE); setErr(null); }}>Annuler</button>
<button className="primary" form="form-new-compte" type="submit" disabled={addingCompte}>{addingCompte ? 'Création…' : 'Créer'}</button>
</div>
}
>
<form id="form-new-compte" onSubmit={addCompte} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<form id="form-new-compte" onSubmit={e => runAddCompte(addCompte, e)} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{err && <div className="error">{err}</div>}
<CompteFormFields state={newCompte} setter={setNewCompte} investisseurs={investisseurs} />
</form>
@@ -222,19 +226,20 @@ export default function ComptesSection() {
open={!!editCompte}
title="Modifier le compte"
onClose={() => { setEditCompte(null); setErr(null); }}
busy={savingEdit}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<button className="danger" type="button" onClick={() => { setEditCompte(null); del(comptes.find(c => c.id === editCompte?.id) ?? editCompte); }}>
<button className="danger" type="button" disabled={savingEdit} onClick={() => { setEditCompte(null); del(comptes.find(c => c.id === editCompte?.id) ?? editCompte); }}>
Supprimer
</button>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" onClick={() => { setEditCompte(null); setErr(null); }}>Annuler</button>
<button className="primary" form="form-edit-compte" type="submit">Enregistrer</button>
<button type="button" disabled={savingEdit} onClick={() => { setEditCompte(null); setErr(null); }}>Annuler</button>
<button className="primary" form="form-edit-compte" type="submit" disabled={savingEdit}>{savingEdit ? 'Enregistrement…' : 'Enregistrer'}</button>
</div>
</div>
}
>
<form id="form-edit-compte" onSubmit={saveEdit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<form id="form-edit-compte" onSubmit={e => runSaveEdit(saveEdit, e)} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{err && <div className="error">{err}</div>}
<CompteFormFields state={editCompte} setter={setEditCompte} investisseurs={investisseurs} />
</form>
@@ -8,7 +8,9 @@ import ConfirmModal from '../../components/ConfirmModal.jsx';
import CountrySelect, { COUNTRIES, FlagIcon } from '../../components/CountrySelect.jsx';
import ResultBanner from '../../components/ResultBanner.jsx';
import { useInvestisseur } from '../../context/InvestisseurContext.jsx';
import { useWorkspace } from '../../context/WorkspaceContext.jsx';
import { memberLabel, fmtDate } from '../../utils/format.js';
import { useAsyncAction } from '../../hooks/useAsyncAction.js';
function dlBlob(content, filename, type) {
const blob = new Blob([content], { type });
@@ -54,7 +56,7 @@ function fmtFiscalite(p) {
return base;
}
const EMPTY_PLAT = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', categorie_plateforme: 'psfp', frais_plateforme: 'aucun', methode_remboursement: 'portefeuille', investisseur_id: null, date_ouverture: '', logo_filename: null, type_pret_defaut: '', freq_interets_defaut: '', referentiel_id: null };
const EMPTY_PLAT = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', categorie_plateforme: 'psfp', frais_plateforme: 'aucun', methode_remboursement: 'portefeuille', investisseur_id: null, date_ouverture: '', logo_filename: null, type_pret_defaut: '', freq_interets_defaut: '', referentiel_id: null, workspace_ids: [] };
const LOGO_BASE = (import.meta.env.VITE_API_URL || '/api').replace(/\/api$/, '') + '/api/logos/';
const logoUrl = (filename) => filename ? LOGO_BASE + filename : null;
@@ -230,6 +232,12 @@ function PlatDetailPanel({ plat, onEdit }) {
label: "Secteurs d'investissement",
value: <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>{platSectsInv.map(s => <span key={s.id} className="chip-sect">{s.nom}</span>)}</div>,
},
{
label: 'Espaces de travail',
value: (plat.workspaces || []).length > 0
? <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>{plat.workspaces.map(w => <span key={w.id} className="chip-workspace">{w.nom}</span>)}</div>
: '—',
},
plat.type_pret_defaut && { label: 'Type de prêt (défaut)', value: { in_fine: 'In fine', amortissable: 'Amortissable', differe: 'Différé' }[plat.type_pret_defaut] ?? plat.type_pret_defaut },
plat.freq_interets_defaut && { label: 'Périodicité (défaut)', value: { mensuel: 'Mensuelle', trimestriel: 'Trimestrielle', in_fine: 'In fine' }[plat.freq_interets_defaut] ?? plat.freq_interets_defaut },
{
@@ -290,20 +298,25 @@ function PlatDetailPanel({ plat, onEdit }) {
/* ── Picker référentiel ──────────────────────────────────────── */
function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }) {
function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err, activeWorkspaces = [] }) {
const [search, setSearch] = useState('');
const [domFilter, setDomFilter] = useState('all'); // 'all' | 'fr' | 'etr'
const [confirming, setConfirming] = useState(null); // ref en cours de confirmation
const [dateOuverture, setDateOuverture] = useState('');
const [chosenWorkspace, setChosenWorkspace] = useState(null); // workspace choisi (si l'utilisateur en a plusieurs)
useEffect(() => {
if (open) { setConfirming(null); setDateOuverture(''); }
}, [open]);
if (open) {
setConfirming(null); setDateOuverture('');
setChosenWorkspace(activeWorkspaces.length === 1 ? activeWorkspaces[0].id : null);
}
}, [open]); // eslint-disable-line
if (!open) return null;
const q = search.trim().toLowerCase();
const filtered = referentiel.filter(r => {
if (chosenWorkspace && !(r.workspaces || []).some(w => w.id === chosenWorkspace)) return false;
if (domFilter === 'fr' && r.domiciliation !== 'FR') return false;
if (domFilter === 'etr' && r.domiciliation === 'FR') return false;
if (q && !r.nom.toLowerCase().includes(q)) return false;
@@ -315,6 +328,34 @@ function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }
return f ? (LOGO_BASE + f) : null;
};
/* ── Étape de choix de l'espace de travail (si plusieurs disponibles) ── */
if (activeWorkspaces.length > 1 && chosenWorkspace === null) {
return (
<Modal open={open} title="Ajouter une plateforme" onClose={onClose} width={480}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 'var(--fs-sm)', lineHeight: 1.5 }}>
Sur quel espace de travail voulez-vous ajouter cette plateforme ? Seules les plateformes
du référentiel éligibles à cet espace vous seront proposées.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{activeWorkspaces.map(w => (
<button key={w.id} type="button" onClick={() => setChosenWorkspace(w.id)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 16px', borderRadius: 8, border: '1.5px solid var(--border)',
background: 'var(--surface)', cursor: 'pointer', textAlign: 'left',
fontSize: 'var(--fs-sm)', fontWeight: 600, color: 'var(--text)',
}}>
{w.libelle_menu || w.nom}
<span style={{ opacity: 0.5 }}>→</span>
</button>
))}
</div>
</div>
</Modal>
);
}
/* ── Étape de confirmation : date d'ouverture du compte ── */
if (confirming) {
const img = imgFor(confirming);
@@ -327,7 +368,7 @@ function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<button type="button" className="ghost" onClick={() => setConfirming(null)}>← Retour</button>
<button type="button" className="primary" onClick={() => onSelect(confirming, dateOuverture)}>
<button type="button" className="primary" onClick={() => onSelect(confirming, dateOuverture, chosenWorkspace)}>
Ajouter
</button>
</div>
@@ -384,6 +425,15 @@ function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }
Les informations (domiciliation, fiscalité…) seront pré-remplies automatiquement.
</p>
{activeWorkspaces.length > 1 && (
<button type="button" onClick={() => setChosenWorkspace(null)}
style={{ alignSelf: 'flex-start', background: 'none', border: 'none', padding: 0,
color: 'var(--primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>
← Espace de travail : {activeWorkspaces.find(w => w.id === chosenWorkspace)?.libelle_menu
|| activeWorkspaces.find(w => w.id === chosenWorkspace)?.nom || ''} (changer)
</button>
)}
{/* Barre de recherche + chips filtre */}
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
@@ -494,7 +544,7 @@ function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }
/* ── Formulaire plateforme partagé (création + édition) ─────────── */
function platFormSteps({ state, setter, logoFile, setLogoFile, logoPreview, setLogoPreview,
investisseurs, referentiel, categoriesInv, secteursInv, isEdit = false, onDelLogo }) {
investisseurs, referentiel, categoriesInv, secteursInv, isEdit = false, onDelLogo, activeWorkspaces = [] }) {
const isFrance = state.domiciliation === 'FR';
@@ -575,6 +625,27 @@ function platFormSteps({ state, setter, logoFile, setLogoFile, logoPreview, setL
<input type="date" value={state.date_ouverture || ''} onChange={e => setter(s => ({ ...s, date_ouverture: e.target.value }))}
style={{ width: '100%', boxSizing: 'border-box' }} />
))}
{/* Espaces de travail (uniquement affiché si plusieurs disponibles) */}
{activeWorkspaces.length > 1 && row('Espaces de travail *', (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{activeWorkspaces.map(w => {
const checked = (state.workspace_ids || []).includes(w.id);
return (
<label key={w.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input type="checkbox" checked={checked} style={{ width: 'auto' }}
onChange={e => setter(s => ({
...s,
workspace_ids: e.target.checked
? [...(s.workspace_ids || []), w.id]
: (s.workspace_ids || []).filter(id => id !== w.id),
}))} />
<span className="chip-workspace" style={{ fontSize: 11 }}>{w.libelle_menu || w.nom}</span>
</label>
);
})}
</div>
), "Cette plateforme ne sera visible/utilisable que dans le(s) espace(s) de travail coché(s).")}
</div>
);
@@ -741,6 +812,7 @@ function platFormSteps({ state, setter, logoFile, setLogoFile, logoPreview, setL
export default function PlateformesSection() {
const [searchParams, setSearchParams] = useSearchParams();
const { activeWorkspaces } = useWorkspace();
// ── State ──────────────────────────────────────────────────────
const [plats, setPlats] = useState([]);
@@ -779,6 +851,8 @@ export default function PlateformesSection() {
const [err, setErr] = useState(null);
const { run: runAddPlat, pending: addingPlat } = useAsyncAction();
const { run: runSaveEditPlat, pending: savingEditPlat } = useAsyncAction();
const [msg, setMsg] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const platImportRef = useRef(null);
@@ -886,8 +960,9 @@ export default function PlateformesSection() {
};
/* ── Ajouter depuis le référentiel ──────────────────────────── */
const addPlatFromRef = async (ref, dateOuverture) => {
const addPlatFromRef = async (ref, dateOuverture, workspaceId) => {
try {
const wsId = workspaceId ?? activeWorkspaces[0]?.id;
await api.post('/plateformes', {
nom: ref.nom,
url: ref.url || '',
@@ -902,6 +977,7 @@ export default function PlateformesSection() {
freq_interets_defaut: ref.freq_interets_defaut || null,
referentiel_id: ref.id,
date_ouverture: dateOuverture || null,
workspace_ids: wsId ? [wsId] : [],
});
setShowAddPicker(false);
await load();
@@ -911,6 +987,10 @@ export default function PlateformesSection() {
/* ── Plateformes ─────────────────────────────────────────────── */
const addPlat = async (e) => {
e.preventDefault(); setErr(null); setMsg(null);
if (activeWorkspaces.length > 1 && (!newPlat.workspace_ids || newPlat.workspace_ids.length === 0)) {
setErr('Sélectionnez au moins un espace de travail.');
return;
}
try {
const created = await api.post('/plateformes', {
...newPlat,
@@ -922,6 +1002,9 @@ export default function PlateformesSection() {
type_pret_defaut: newPlat.type_pret_defaut || null,
freq_interets_defaut: newPlat.freq_interets_defaut || null,
referentiel_id: newPlat.referentiel_id ? Number(newPlat.referentiel_id) : null,
workspace_ids: (newPlat.workspace_ids && newPlat.workspace_ids.length > 0)
? newPlat.workspace_ids
: (activeWorkspaces[0] ? [activeWorkspaces[0].id] : []),
});
if (newPlatLogoFile) await uploadLogo(created.id, newPlatLogoFile);
setNewPlat(EMPTY_PLAT);
@@ -997,6 +1080,7 @@ export default function PlateformesSection() {
secteurs_inv_ids: platSectsInv.map(s => s.id),
inherited_cat_ids: platCatsInv.filter(c => c.is_inherited).map(c => c.id),
inherited_sect_ids: platSectsInv.filter(s => s.is_inherited).map(s => s.id),
workspace_ids: (p.workspaces || []).map(w => w.id),
domiciliation: p.domiciliation || 'france',
fiscalite: p.fiscalite || 'flat_tax',
taux_fiscalite_locale: p.taux_fiscalite_locale ?? '',
@@ -1017,6 +1101,10 @@ export default function PlateformesSection() {
const saveEditPlat = async (e) => {
e.preventDefault(); setErr(null); setMsg(null);
if (activeWorkspaces.length > 1 && (!editPlat.workspace_ids || editPlat.workspace_ids.length === 0)) {
setErr('Sélectionnez au moins un espace de travail.');
return;
}
try {
await api.put(`/plateformes/${editPlat.id}`, {
nom: editPlat.nom, url: editPlat.url || '',
@@ -1032,6 +1120,9 @@ export default function PlateformesSection() {
date_ouverture: editPlat.date_ouverture || null,
type_pret_defaut: editPlat.type_pret_defaut || null,
freq_interets_defaut: editPlat.freq_interets_defaut || null,
workspace_ids: (editPlat.workspace_ids && editPlat.workspace_ids.length > 0)
? editPlat.workspace_ids
: (activeWorkspaces[0] ? [activeWorkspaces[0].id] : []),
});
if (editPlatLogoFile) await uploadLogo(editPlat.id, editPlatLogoFile);
// Sauvegarder les associations catégories/secteurs d'investissement
@@ -1114,8 +1205,7 @@ export default function PlateformesSection() {
<th style={{ padding: '10px 8px', width: 40 }}></th>
<th style={{ padding: '10px 16px', textAlign: 'left' }}>Nom</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Domiciliation</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Catégories</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Secteurs</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Workspaces</th>
{multiDetenteur && <th style={{ padding: '10px 8px', textAlign: 'left' }}>Détenteur</th>}
<th style={{ padding: '10px 16px', textAlign: 'center', width: 60 }}>Invest.</th>
<th style={{ padding: '10px 8px', width: 40 }}></th>
@@ -1123,13 +1213,12 @@ export default function PlateformesSection() {
</thead>
<tbody>
{plats.length === 0 && (
<tr><td colSpan={multiDetenteur ? 8 : 7} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune plateforme</td></tr>
<tr><td colSpan={multiDetenteur ? 7 : 6} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune plateforme</td></tr>
)}
{plats.map((p, i) => {
const imgSrc = p.icone_filename ? logoUrl(p.icone_filename) : p.logo_filename ? logoUrl(p.logo_filename) : null;
const inv = platInvestisseur(p);
const cats = p.categories_inv || [];
const sects = p.secteurs_inv || [];
const wsList = p.workspaces || [];
return (
<tr key={p.id}
onClick={() => setSelectedPlat(selectedPlat?.id === p.id ? null : p)}
@@ -1158,16 +1247,9 @@ export default function PlateformesSection() {
</td>
<td style={{ padding: '10px 8px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{cats.slice(0, 2).map(c => <span key={c.id} className="chip-cat" style={{ fontSize: 11 }}>{c.nom}</span>)}
{cats.length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{cats.length - 2}</span>}
{cats.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
</div>
</td>
<td style={{ padding: '10px 8px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{sects.slice(0, 2).map(s => <span key={s.id} className="chip-sect" style={{ fontSize: 11 }}>{s.nom}</span>)}
{sects.length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{sects.length - 2}</span>}
{sects.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
{wsList.slice(0, 2).map(w => <span key={w.id} className="chip-workspace" style={{ fontSize: 11 }}>{w.nom}</span>)}
{wsList.length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{wsList.length - 2}</span>}
{wsList.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
</div>
</td>
{multiDetenteur && (
@@ -1245,6 +1327,7 @@ export default function PlateformesSection() {
onSelect={addPlatFromRef}
onManual={() => { setNewPlat(EMPTY_PLAT); setErr(null); setShowNewPlat(true); }}
err={err}
activeWorkspaces={activeWorkspaces}
/>
{/* ── Modal : Ajouter une plateforme ── */}
@@ -1255,12 +1338,14 @@ export default function PlateformesSection() {
logoPreview: newPlatLogoPreview, setLogoPreview: setNewPlatLogoPreview,
investisseurs, referentiel,
categoriesInv: [], secteursInv: [],
activeWorkspaces,
});
return (
<PlateformeFormModal
title="Nouvelle plateforme"
onClose={() => { setShowNewPlat(false); setNewPlat(EMPTY_PLAT); setNewPlatLogoFile(null); setNewPlatLogoPreview(null); setErr(null); }}
onSubmit={addPlat}
onSubmit={e => runAddPlat(addPlat, e)}
saving={addingPlat}
err={err}
submitLabel="Créer"
width={600}
@@ -1279,12 +1364,14 @@ export default function PlateformesSection() {
categoriesInv, secteursInv,
isEdit: true,
onDelLogo: () => delLogoPlat(editPlat.id),
activeWorkspaces,
});
return (
<PlateformeFormModal
title="Modifier la plateforme"
onClose={() => { setEditPlat(null); setEditPlatLogoFile(null); setEditPlatLogoPreview(null); setErr(null); }}
onSubmit={saveEditPlat}
onSubmit={e => runSaveEditPlat(saveEditPlat, e)}
saving={savingEditPlat}
err={err}
submitLabel="Enregistrer"
width={600}
+5
View File
@@ -1015,6 +1015,11 @@ tr:hover td { background: var(--surface-2); }
font-weight: 500; background: rgba(16,185,129,.12); color: var(--success);
border: 1px solid rgba(16,185,129,.25);
}
.chip-workspace {
display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 12px;
font-weight: 500; background: rgba(245,158,11,.12); color: #d97706;
border: 1px solid rgba(245,158,11,.25);
}
/* ── Filtre par catégorie — bloc "Mes documents" (Mon compte) ── */
.doc-cat-tabs { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }