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

+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>
);
}