Initial commit

This commit is contained in:
Olivier CROGUENNEC
2026-06-13 14:57:15 +02:00
commit 48ed7fe65e
209 changed files with 49979 additions and 0 deletions
@@ -0,0 +1,351 @@
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'];
/* ── Helpers dates ───────────────────────────────────────────────── */
function endOfMonth(Y, M) {
const d = new Date(Y, M, 0); // day 0 of month M+1 = last day of month M
return `${Y}-${String(M).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
}
function startOfMonth(Y, M) {
return `${Y}-${String(M).padStart(2,'0')}-01`;
}
function ChevronDown({ size = 10 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9l6 6 6-6"/>
</svg>
);
}
/* ── Composant ───────────────────────────────────────────────────── */
export default function CapitalMensuelTable({ allRows, allRembs, allReinvests, plats, expandButton }) {
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
const [annee, setAnnee] = useState(currentYear);
/* ── Toggle consolidation détenteurs ── */
const [groupByNom, setGroupByNom] = useState(() => {
try { return localStorage.getItem('cl_tip_group_by_nom') === 'true'; } catch { return false; }
});
const toggleGroupByNom = () => {
setGroupByNom(v => {
const next = !v;
try { localStorage.setItem('cl_tip_group_by_nom', String(next)); } catch {}
return next;
});
};
/* ── Années disponibles ── */
const availableYears = useMemo(() => {
const set = new Set(allRows.map(r => r.date_souscription?.slice(0,4)).filter(Boolean));
return [...set].map(Number).sort((a,b) => a - b);
}, [allRows]);
/* ── Precompute : reinvests et capital_remb par investissement ── */
const reinvestByInv = useMemo(() => {
const map = {};
for (const rv of allReinvests) {
const id = rv.investissement_id;
if (!id) continue;
if (!map[id]) map[id] = [];
map[id].push({ date: rv.date_reinvestissement?.slice(0,10), montant: rv.montant || 0 });
}
return map;
}, [allReinvests]);
const capRembByInv = useMemo(() => {
const map = {};
for (const rb of allRembs) {
const id = rb.investissement_id;
if (!id || rb.type !== 'normal') continue;
if (!map[id]) map[id] = [];
map[id].push({ date: rb.date_remb?.slice(0,10), capital: rb.capital || 0 });
}
return map;
}, [allRembs]);
const lastRembDateMap = useMemo(() => {
const map = {};
for (const rb of allRembs) {
const id = rb.investissement_id;
const d = rb.date_remb?.slice(0,10);
if (!id || !d) continue;
if (!map[id] || d > map[id]) map[id] = d;
}
return map;
}, [allRembs]);
/* ── Calcul capital encours par plateforme par mois ─────────────
* Pour chaque mois M :
* - L'investissement est actif si souscrit avant fin M
* ET (statut actif aujourd'hui OU date_fin >= début M)
* - capital = montant_investi + reinvests_≤_finM capital_remboursé_≤_finM
* ─────────────────────────────────────────────────────────────── */
const { grid, multiDetenteur } = useMemo(() => {
if (!allRows.length) return { grid: null, multiDetenteur: false };
const ACTIVE = ['en_cours', 'en_retard', 'procedure'];
// Index plateformes par id (pour nom + detenteur)
const platMap = {};
for (const p of plats) platMap[p.id] = p;
// Pour chaque investissement, capital encours au end of month M
const getCapitalAtEndOfMonth = (inv, Y, M) => {
const endM = endOfMonth(Y, M);
if (inv.date_souscription > endM) return 0;
const startM = startOfMonth(Y, M);
const isActive = ACTIVE.includes(inv.statut) ||
((inv.date_cible || lastRembDateMap[inv.id] || null) >= startM);
if (!isActive) return 0;
const reinvM = (reinvestByInv[inv.id] || [])
.filter(rv => rv.date && rv.date <= endM)
.reduce((s, rv) => s + rv.montant, 0);
const capRembM = (capRembByInv[inv.id] || [])
.filter(rb => rb.date && rb.date <= endM)
.reduce((s, rb) => s + rb.capital, 0);
return Math.max(0, inv.montant_investi + reinvM - capRembM);
};
// Agréger par plateforme (id)
const byPlat = {};
for (const inv of allRows) {
const pid = inv.plateforme_id;
if (!byPlat[pid]) {
const p = platMap[pid] || {};
byPlat[pid] = {
id: pid,
nom: inv.plateforme_nom || p.nom || '—',
investisseur_id: p.investisseur_id ?? inv.investisseur_id ?? null,
detenteur_nom: inv.plateforme_detenteur_nom || null,
months: Array(12).fill(0),
};
}
const row = byPlat[pid];
for (let m = 1; m <= 12; m++) {
row.months[m-1] += getCapitalAtEndOfMonth(inv, annee, m);
}
}
const allPlats = Object.values(byPlat).filter(p => p.months.some(v => v > 0));
// Détection multi-détenteur sur données brutes
const multi = new Set(allPlats.map(p => p.investisseur_id).filter(v => v != null)).size > 1;
// Consolidation par nom si demandée
let rows;
if (groupByNom && multi) {
const byNom = {};
for (const row of allPlats) {
if (!byNom[row.nom]) {
byNom[row.nom] = { id: row.nom, nom: row.nom, investisseur_id: null, detenteur_nom: null, months: [...row.months] };
} else {
for (let i = 0; i < 12; i++) byNom[row.nom].months[i] += row.months[i];
}
}
rows = Object.values(byNom);
} else {
rows = allPlats;
}
rows = rows
.filter(p => p.months.some(v => v > 0))
.sort((a,b) => b.months.reduce((s,v) => s+v, 0) - a.months.reduce((s,v) => s+v, 0));
return { grid: rows, multiDetenteur: multi };
}, [allRows, annee, reinvestByInv, capRembByInv, lastRembDateMap, plats, groupByNom]);
/* ── Totaux et moyennes ── */
const stats = useMemo(() => {
if (!grid) return null;
const monthTotals = Array.from({ length: 12 }, (_, i) =>
grid.reduce((s, row) => s + row.months[i], 0));
const grandTotal = monthTotals.reduce((s, v) => s + v, 0);
// Moyenne : average of non-zero months per platform
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 monthMoyennes = Array.from({ length: 12 }, (_, i) =>
grid.reduce((s, row) => s + row.months[i], 0));
const nonZeroMonthTotals = monthTotals.filter(v => v > 0);
const globalMoyenne = nonZeroMonthTotals.length
? nonZeroMonthTotals.reduce((s,v) => s+v, 0) / nonZeroMonthTotals.length
: 0;
return { monthTotals, grandTotal, platMoyennes, platPoids, totalMoyenne, monthMoyennes, globalMoyenne };
}, [grid]);
/* ── Sélecteur d'années ── */
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;
/* ── Rendu ─────────────────────────────────────────────────────── */
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)' }}>
{`Capital investi · ${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>
{/* Sélecteur d'années */}
<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' }}>
Aucun capital investi 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">
<span style={{ display:'flex', alignItems:'center', gap:6 }}>
Plateforme
{multiDetenteur && (
<button
onClick={() => toggleGroupByNom()}
title={groupByNom ? 'Vue consolidée — cliquer pour détailler par détenteur' : 'Vue détaillée — cliquer pour consolider par plateforme'}
style={{
display:'inline-flex', alignItems:'center', gap:3,
background:'rgba(255,255,255,0.15)', border:'1px solid rgba(255,255,255,0.3)',
borderRadius:4, padding:'2px 5px', cursor:'pointer',
fontSize:10, fontWeight:600, color:'#fff', letterSpacing:'.03em',
lineHeight:1.4, whiteSpace:'nowrap', transition:'background .15s',
}}>
{groupByNom ? 'Consolidé' : 'Détaillé'}
<span style={{ display:'inline-flex', transition:'transform .2s', transform: groupByNom ? 'rotate(180deg)' : 'rotate(0deg)' }}>
<ChevronDown size={9} />
</span>
</button>
)}
</span>
</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}
{!groupByNom && 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>
);
}
+196
View File
@@ -0,0 +1,196 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { api } from '../api.js';
/**
* CategorySelect — multi-select with checkboxes + inline "Add category"
*
* Props:
* selected : number[] — ids sélectionnés
* onChange : (ids: number[]) => void
* categories : { id, nom }[] — liste complète fournie par le parent
* onCategoryAdded : ({ id, nom }) => void — appelé après création inline
*
* Le dropdown est rendu en position:fixed (calculé depuis getBoundingClientRect)
* pour échapper au overflow:auto des modales parentes.
*/
export default function CategorySelect({ selected = [], onChange, categories = [], onCategoryAdded }) {
const [open, setOpen] = useState(false);
const [adding, setAdding] = useState(false);
const [newName, setNewName] = useState('');
const [err, setErr] = useState(null);
const [busy, setBusy] = useState(false);
const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 0 });
const wrapRef = useRef(null);
const triggerRef = useRef(null);
/* ── Position fixe calculée à chaque ouverture ───────────────── */
useLayoutEffect(() => {
if (!open || !triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
setDropPos({
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
});
}, [open]);
/* ── Fermeture : clic extérieur + scroll + resize ────────────── */
useEffect(() => {
if (!open) return;
const close = (e) => {
if (wrapRef.current?.contains(e.target)) return;
// Exclure aussi le dropdown lui-même (rendu en fixed hors du wrap)
const drop = document.getElementById('cat-select-dropdown-portal');
if (drop?.contains(e.target)) return;
setOpen(false);
};
const closeOnScroll = (e) => {
const drop = document.getElementById('cat-select-dropdown-portal');
if (drop?.contains(e.target)) return;
setOpen(false);
};
document.addEventListener('mousedown', close);
window.addEventListener('scroll', closeOnScroll, true);
window.addEventListener('resize', closeOnScroll);
return () => {
document.removeEventListener('mousedown', close);
window.removeEventListener('scroll', closeOnScroll, true);
window.removeEventListener('resize', closeOnScroll);
};
}, [open]);
const toggle = (id) => {
onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]);
};
const addCategory = async (e) => {
e.preventDefault();
if (!newName.trim()) return;
setBusy(true); setErr(null);
try {
const cat = await api.post('/categories', { nom: newName.trim() });
onCategoryAdded(cat);
onChange([...selected, cat.id]);
setNewName('');
setAdding(false);
} catch (e) {
setErr(e.message);
} finally {
setBusy(false);
}
};
/* ── Label du bouton déclencheur ─────────────────────────────── */
const label = (() => {
if (selected.length === 0) return "Aucune catégorie d'investissement";
const names = categories.filter(c => selected.includes(c.id)).map(c => c.nom);
if (names.length <= 2) return names.join(', ');
return `${names.length} catégories d'invest.`;
})();
/* ── Dropdown rendu en position:fixed ────────────────────────── */
const dropdown = open ? (
<div
id="cat-select-dropdown-portal"
className="cat-select-dropdown"
role="listbox"
aria-multiselectable="true"
style={{
position: 'fixed',
top: dropPos.top,
left: dropPos.left,
width: dropPos.width,
zIndex: 9999,
}}
>
{/* Liste des catégories */}
{categories.length === 0 && (
<div className="cat-select-empty">Aucune catégorie d'investissement disponible</div>
)}
{categories.map(cat => {
const checked = selected.includes(cat.id);
return (
<label key={cat.id} className={`cat-select-item${checked ? ' checked' : ''}`}>
<input
type="checkbox"
checked={checked}
onChange={() => toggle(cat.id)}
/>
<span>{cat.nom}</span>
{checked && (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ marginLeft: 'auto', color: 'var(--accent)' }} aria-hidden="true">
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
</label>
);
})}
{/* Séparateur + ajout */}
<div className="cat-select-sep" />
{!adding ? (
<button type="button" className="cat-select-add-btn"
onClick={() => { setAdding(true); setErr(null); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
aria-hidden="true">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Ajouter une catégorie d'investissement
</button>
) : (
<form onSubmit={addCategory} className="cat-select-new-form">
<input
autoFocus
value={newName}
onChange={e => setNewName(e.target.value)}
placeholder="Nom de la catégorie d'investissement"
maxLength={100}
/>
<div className="cat-select-new-actions">
<button type="submit" className="primary" disabled={busy || !newName.trim()}>
{busy ? '…' : 'Créer'}
</button>
<button type="button" className="ghost"
onClick={() => { setAdding(false); setNewName(''); setErr(null); }}>
Annuler
</button>
</div>
{err && <div className="cat-select-err">{err}</div>}
</form>
)}
</div>
) : null;
return (
<>
<div ref={wrapRef} className="cat-select-wrap">
<button
ref={triggerRef}
type="button"
className={`cat-select-trigger${open ? ' open' : ''}`}
onClick={() => { setOpen(o => !o); setAdding(false); setErr(null); }}
aria-haspopup="listbox"
aria-expanded={open}
>
<span className="cat-select-label">{label}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ flexShrink: 0, transition: 'transform .15s', transform: open ? 'rotate(0deg)' : 'rotate(180deg)' }}
aria-hidden="true">
<path d="M18 15l-6-6-6 6"/>
</svg>
</button>
</div>
{/* Dropdown rendu hors du wrap pour échapper à overflow:auto */}
{dropdown}
</>
);
}
@@ -0,0 +1,349 @@
import { useEffect, useState } from 'react';
import { api } from '../api.js';
const MOIS_LABELS = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
const MOIS_NUMS = ['01','02','03','04','05','06','07','08','09','10','11','12'];
const r2 = v => Math.round((v ?? 0) * 100) / 100;
const fmtN = v => Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const fmtI = v => Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
const fullName = l => { const n = l.investisseur_nom ?? ''; const p = l.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); };
const BADGE_AUTO = { text: 'déclaration automatique', bg: 'rgba(22,163,74,0.1)', color: '#16a34a' };
const BADGE_DECL = { text: 'à déclarer', bg: 'rgba(239,68,68,0.1)', color: '#dc2626' };
function BadgeTag({ badge }) {
if (!badge) return null;
return (
<span style={{ fontSize: 10, padding: '1px 6px', borderRadius: 10, background: badge.bg, color: badge.color, fontWeight: 600, whiteSpace: 'nowrap', marginLeft: 6 }}>
{badge.text}
</span>
);
}
/* breakdown item: { nom, val, badge } */
function Case2042({ code, label, note, value, breakdown }) {
if (!value) return null;
return (
<div style={{ borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'grid', gridTemplateColumns: '90px 1fr auto', gap: '0 12px', alignItems: 'center', padding: '10px 14px' }}>
<div style={{ fontFamily: 'monospace', fontWeight: 800, fontSize: 'var(--fs-base)', color: 'var(--primary)', background: 'rgba(99,102,241,0.07)', borderRadius: 6, padding: '2px 6px', textAlign: 'center', letterSpacing: '.04em' }}>{code}</div>
<div>
<div style={{ fontSize: 'var(--fs-sm)', fontWeight: 600 }}>{label}</div>
{note && <div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 2 }}>{note}</div>}
</div>
<div style={{ fontWeight: 800, fontSize: 'var(--fs-lg)', whiteSpace: 'nowrap', color: 'var(--text)' }}>{fmtI(value)} </div>
</div>
{breakdown && breakdown.length > 0 && (
<div style={{ padding: '0 14px 10px 118px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{breakdown.map((p, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
<span style={{ display: 'flex', alignItems: 'center' }}>
{p.nom}
<BadgeTag badge={p.badge} />
</span>
<span style={{ fontWeight: 500, marginLeft: 12 }}>{fmtI(p.val)} </span>
</div>
))}
</div>
)}
</div>
);
}
export default function Cerfa2042Preview({ annee, activeView, pfoAssujetti }) {
const LS_EXCL = 'cl_2778_excluded_plats';
const [data2561, setData2561] = useState(null);
const [data2778, setData2778] = useState(null);
const [pfuList, setPfuList] = useState([]);
const [excluded, setExcluded] = useState(() => {
try { return new Set(JSON.parse(localStorage.getItem(LS_EXCL)) ?? []); }
catch { return new Set(); }
});
const [loading, setLoading] = useState(true);
const [view, setView] = useState('matrice');
const [filterMode, setFilterMode] = useState('all'); // 'all' | 'auto' | 'decl'
useEffect(() => {
setLoading(true);
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
Promise.all([
api.get('/taxreport/cerfa2561', { annee, ...scopeParams }),
pfuList.length === 0 ? api.get('/pfu') : Promise.resolve(null),
api.get('/taxreport/2778', { annee, ...scopeParams }),
]).then(([d2561, pfu, d2778]) => {
setData2561(d2561);
if (pfu) setPfuList(pfu);
if (d2778) setData2778(d2778);
})
.finally(() => setLoading(false));
}, [annee, activeView]); // eslint-disable-line
const frLignes = (data2561?.lignes ?? []).filter(l => l.domiciliation === 'FR');
const platEtr = (data2778?.plateformes ?? []).filter(p => !excluded.has(p.id));
const ratesForYear = () => {
const sorted = [...pfuList].sort((a, b) => b.annee - a.annee);
const m = sorted.find(r => r.annee <= Number(annee));
if (!m) return { pfo: 0.128, csg: 0.106, crds: 0.005, solidarite: 0.075 };
return { pfo: (m.impot_revenu ?? 12.8) / 100, csg: (m.csg ?? 10.6) / 100, crds: (m.crds ?? 0.5) / 100, solidarite: (m.solidarite ?? 7.5) / 100 };
};
const rates = ratesForYear();
const totalTaxRate = rates.pfo + rates.csg + rates.crds + rates.solidarite;
/* ── Suivi mensuel combiné ── */
const matriceView = (
<div style={{ overflowX: 'auto' }}>
{loading && <div className="card text-muted">Chargement</div>}
{!loading && frLignes.length === 0 && platEtr.length === 0 && (
<div className="card"><p className="text-muted" style={{ margin: 0 }}>Aucune donnée pour {annee}.</p></div>
)}
{!loading && (frLignes.length > 0 || platEtr.length > 0) && (
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, padding: '14px 14px 10px' }}>
<h4 style={{ margin: 0, fontWeight: 700 }}>Suivi mensuel des intérêts bruts</h4>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Revenus {annee} toutes plateformes</span>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--fs-xs)' }}>
<thead>
<tr style={{ background: 'var(--surface-2)', borderBottom: '1px solid var(--border)' }}>
<th style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 600, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Plateforme Détenteur</th>
{MOIS_LABELS.map(m => (
<th key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 600, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>{m}</th>
))}
<th style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 600, color: 'var(--text-muted)' }}>Total</th>
</tr>
</thead>
<tbody>
{frLignes.length > 0 && (
<tr style={{ background: 'rgba(22,163,74,0.05)' }}>
<td colSpan={14} style={{ padding: '4px 12px', fontSize: 'var(--fs-xs)', fontWeight: 600, color: '#16a34a', letterSpacing: '.04em', textTransform: 'uppercase' }}>
Plateformes françaises déclaration automatique
</td>
</tr>
)}
{frLignes.map(l => {
const total = r2(Object.values(l.mois ?? {}).reduce((s, m) => s + (m.interets_bruts ?? 0), 0));
return (
<tr key={`fr_${l.plateforme_id}_${l.investisseur_id}`} style={{ borderBottom: '1px solid var(--border)', background: 'transparent' }}>
<td style={{ padding: '6px 12px', fontWeight: 500, whiteSpace: 'nowrap' }}>
{l.plateforme_nom}
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}> {fullName(l)}</span>
</td>
{MOIS_NUMS.map(m => {
const v = l.mois?.[m]?.interets_bruts ?? 0;
return <td key={m} style={{ padding: '6px 8px', textAlign: 'right', color: v > 0 ? 'var(--text)' : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'}</td>;
})}
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700 }}>{total > 0 ? fmtN(total) + ' €' : '—'}</td>
</tr>
);
})}
{frLignes.length > 0 && (
<tr style={{ borderTop: '2px solid var(--border)', background: 'rgba(22,163,74,0.06)' }}>
<td style={{ padding: '6px 12px', fontWeight: 700, color: '#16a34a' }}>Total Plateformes françaises (brut)</td>
{MOIS_NUMS.map(m => {
const v = r2(frLignes.reduce((s, l) => s + (l.mois?.[m]?.interets_bruts ?? 0), 0));
return <td key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 700, color: v > 0 ? '#16a34a' : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'}</td>;
})}
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700, color: '#16a34a' }}>
{fmtN(r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + (m.interets_bruts ?? 0), 0)), 0)))}
</td>
</tr>
)}
{platEtr.length > 0 && (
<tr style={{ background: 'rgba(239,68,68,0.05)' }}>
<td colSpan={14} style={{ padding: '4px 12px', fontSize: 'var(--fs-xs)', fontWeight: 600, color: '#dc2626', letterSpacing: '.04em', textTransform: 'uppercase' }}>
Plateformes étrangères à déclarer
</td>
</tr>
)}
{platEtr.map(p => {
const total = r2(Object.values(p.mois ?? {}).reduce((s, v) => s + v, 0));
return (
<tr key={`etr_${p.id}`} style={{ borderBottom: '1px solid var(--border)', background: 'transparent' }}>
<td style={{ padding: '6px 12px', fontWeight: 500, whiteSpace: 'nowrap' }}>
{p.nom}
{(p.investisseur_nom || p.investisseur_prenom) && (
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}> {fullName(p)}</span>
)}
</td>
{MOIS_NUMS.map(m => {
const v = p.mois?.[m] ?? 0;
return <td key={m} style={{ padding: '6px 8px', textAlign: 'right', color: v > 0 ? 'var(--text)' : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'}</td>;
})}
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700 }}>{total > 0 ? fmtN(total) + ' €' : '—'}</td>
</tr>
);
})}
</tbody>
<tfoot>
{platEtr.length > 0 && (
<tr style={{ borderTop: '2px solid var(--border)', background: 'rgba(239,68,68,0.06)' }}>
<td style={{ padding: '6px 12px', fontWeight: 700, color: '#dc2626' }}>Total Plateformes étrangères (brut)</td>
{MOIS_NUMS.map(m => {
const v = r2(platEtr.reduce((s, p) => s + (p.mois?.[m] ?? 0), 0));
return <td key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 700, color: v > 0 ? '#dc2626' : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'}</td>;
})}
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700, color: '#dc2626' }}>
{fmtN(r2(platEtr.reduce((s, p) => s + r2(Object.values(p.mois ?? {}).reduce((ss, v) => ss + v, 0)), 0)))}
</td>
</tr>
)}
<tr style={{ borderTop: '2px solid var(--border)', background: 'var(--surface-2)' }}>
<td style={{ padding: '6px 12px', fontWeight: 700 }}>Total général (brut)</td>
{MOIS_NUMS.map(m => {
const v = r2(frLignes.reduce((s, l) => s + (l.mois?.[m]?.interets_bruts ?? 0), 0) + platEtr.reduce((s, p) => s + (p.mois?.[m] ?? 0), 0));
return <td key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 700, color: v > 0 ? 'var(--text)' : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'}</td>;
})}
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700 }}>
{fmtN(r2(
frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + (m.interets_bruts ?? 0), 0)), 0) +
platEtr.reduce((s, p) => s + r2(Object.values(p.mois ?? {}).reduce((ss, v) => ss + v, 0)), 0)
))}
</td>
</tr>
{/* Taux total prélevé */}
<tr style={{ background: 'transparent' }}>
<td style={{ padding: '4px 12px', color: 'var(--text-muted)', fontSize: 'var(--fs-xs)' }}>
Taux total prélevé ({(totalTaxRate * 100).toFixed(1)} %)
</td>
{MOIS_NUMS.map(m => {
const brut = r2(frLignes.reduce((s, l) => s + (l.mois?.[m]?.interets_bruts ?? 0), 0) + platEtr.reduce((s, p) => s + (p.mois?.[m] ?? 0), 0));
const prelevFR = r2(frLignes.reduce((s, l) => s + ((l.mois?.[m]?.prelev_sociaux ?? 0) + (l.mois?.[m]?.prelev_forfaitaire ?? 0)), 0));
const prelevEtr = r2(platEtr.reduce((s, p) => s + (p.mois?.[m] ?? 0), 0) * totalTaxRate);
const taux = brut > 0 ? ((prelevFR + prelevEtr) / brut * 100).toFixed(1) + ' %' : '—';
return <td key={m} style={{ padding: '4px 8px', textAlign: 'right', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>{taux}</td>;
})}
<td style={{ padding: '4px 12px', textAlign: 'right', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
{(totalTaxRate * 100).toFixed(1)} %
</td>
</tr>
{/* Total intérêt net */}
<tr style={{ background: 'rgba(22,163,74,0.06)', borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '6px 12px', fontWeight: 700, color: 'var(--success)' }}>Total intérêt net</td>
{MOIS_NUMS.map(m => {
const netFR = r2(frLignes.reduce((s, l) => {
const mo = l.mois?.[m];
return s + ((mo?.interets_bruts ?? 0) - (mo?.prelev_sociaux ?? 0) - (mo?.prelev_forfaitaire ?? 0));
}, 0));
const netEtr = r2(platEtr.reduce((s, p) => s + (p.mois?.[m] ?? 0), 0) * (1 - totalTaxRate));
const net = r2(netFR + netEtr);
return <td key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 700, color: net > 0 ? 'var(--success)' : 'var(--text-muted)' }}>{net > 0 ? fmtN(net) : '—'}</td>;
})}
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700, color: 'var(--success)' }}>
{fmtN(r2(
frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + ((m.interets_bruts ?? 0) - (m.prelev_sociaux ?? 0) - (m.prelev_forfaitaire ?? 0)), 0)), 0) +
platEtr.reduce((s, p) => s + r2(Object.values(p.mois ?? {}).reduce((ss, v) => ss + v, 0)) * (1 - totalTaxRate), 0)
))}
</td>
</tr>
<tr style={{ background: 'var(--surface-2)' }}>
<td colSpan={14} style={{ padding: '8px 12px', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', borderTop: '1px solid var(--border)' }}>
Plateformes françaises : prélèvements à la source (taux {(totalTaxRate * 100).toFixed(1)} %) déclaration automatique sur 2042 &nbsp;·&nbsp; Plateformes étrangères : à déclarer
</td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
);
/* ── Données 2042 — cases mixtes par code ── */
const données2042View = (() => {
if (loading) return <div className="card text-muted">Chargement</div>;
// Totaux FR
const fr2TT = frLignes.reduce((s, l) => s + (l.case_2TT ?? 0), 0);
const fr2TR = frLignes.reduce((s, l) => s + (l.case_2TR ?? 0), 0);
const fr2BH = frLignes.reduce((s, l) => s + (l.case_2BH ?? 0), 0);
const fr2CK = frLignes.reduce((s, l) => s + (l.case_2CK ?? 0), 0);
const fr2TY = frLignes.reduce((s, l) => s + (l.case_2TY ?? 0), 0);
// Totaux étrangers
const etrBA = p => r2(Object.values(p.mois ?? {}).reduce((s, v) => s + v, 0));
const applyFilter = items => filterMode === 'all' ? items : items.filter(i => (filterMode === 'auto' ? i.badge === BADGE_AUTO : i.badge === BADGE_DECL));
const totalEtrBA = r2(platEtr.reduce((s, p) => s + etrBA(p), 0));
const totalEtrIA = Math.round(totalEtrBA * rates.pfo);
// Breakdown 2TT : FR seulement
const bd2TT = applyFilter(frLignes.filter(l => l.case_2TT > 0).map(l => ({ nom: `${l.plateforme_nom}${fullName(l)}`, val: l.case_2TT, badge: BADGE_AUTO })));
const total2TT = bd2TT.reduce((s, i) => s + i.val, 0);
// Breakdown 2TR : FR (case_2TR) + étrangères
const bd2TR = applyFilter([
...frLignes.filter(l => l.case_2TR > 0).map(l => ({ nom: `${l.plateforme_nom}${fullName(l)}`, val: l.case_2TR, badge: BADGE_AUTO })),
...platEtr.filter(p => etrBA(p) > 0).map(p => ({ nom: p.nom, val: Math.round(etrBA(p)), badge: BADGE_DECL })),
]);
const total2TR = bd2TR.reduce((s, i) => s + i.val, 0);
// Breakdown 2BH : FR + étrangères
const bd2BH = applyFilter([
...frLignes.filter(l => l.case_2BH > 0).map(l => ({ nom: `${l.plateforme_nom}${fullName(l)}`, val: l.case_2BH, badge: BADGE_AUTO })),
...platEtr.filter(p => etrBA(p) > 0).map(p => ({ nom: p.nom, val: Math.round(etrBA(p)), badge: BADGE_DECL })),
]);
const total2BH = bd2BH.reduce((s, i) => s + i.val, 0);
// Breakdown 2CK : FR (PFNL retenu) + étrangères (acompte 2778-SD)
const bd2CK = applyFilter([
...frLignes.filter(l => l.case_2CK > 0).map(l => ({ nom: `${l.plateforme_nom}${fullName(l)}`, val: l.case_2CK, badge: BADGE_AUTO })),
...platEtr.filter(p => etrBA(p) > 0).map(p => ({ nom: p.nom, val: Math.round(etrBA(p) * rates.pfo), badge: BADGE_DECL })),
]);
const total2CK = bd2CK.reduce((s, i) => s + i.val, 0);
// Breakdown 2TY : FR seulement
const bd2TY = applyFilter(frLignes.filter(l => l.case_2TY > 0).map(l => ({ nom: `${l.plateforme_nom}${fullName(l)}`, val: l.case_2TY, badge: BADGE_AUTO })));
const total2TY = bd2TY.reduce((s, i) => s + i.val, 0);
return (
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<h4 style={{ margin: 0, fontWeight: 700 }}>Report annuel Déclaration 2042</h4>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Revenus {annee} à reporter sur la 2042 déposée en {Number(annee) + 1}</span>
<div style={{ flex: 1 }} />
<div style={{ display: 'flex', gap: 2, background: 'var(--surface-2)', borderRadius: 8, padding: 2, border: '1px solid var(--border)' }}>
{[['all','Tout'],['auto','Automatique'],['decl','À déclarer']].map(([val, label]) => (
<button key={val} onClick={() => setFilterMode(val)} style={{ padding: '4px 10px', borderRadius: 6, border: 'none', cursor: 'pointer', fontSize: 'var(--fs-xs)', fontWeight: 600, background: filterMode === val ? 'var(--primary)' : 'transparent', color: filterMode === val ? '#fff' : 'var(--text-muted)', transition: 'all .15s' }}>
{label}
</button>
))}
</div>
</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden', marginBottom: 14 }}>
<Case2042 code="2TT" label="Produits des prêts participatifs" note="Intérêts bruts — plateformes françaises (financement participatif, case KR)" value={total2TT || undefined} breakdown={bd2TT} />
<Case2042 code="2TR" label="Produits de placement à revenu fixe" note="Intérêts bruts — plateformes françaises (case AR) + étrangères (base imposable BA)" value={total2TR || undefined} breakdown={bd2TR} />
<Case2042 code="2BH" label="Produits pour lesquels les PS ont déjà été appliqués" note="Même montant que 2TT/2TR — neutralise la double imposition aux prélèvements sociaux" value={total2BH || undefined} breakdown={bd2BH} />
<Case2042 code="2CK" label="Crédit d'impôt — prélèvement forfaitaire déjà retenu" note={`FR : PFNL retenu à la source (12,8 %) · Étranger : acompte versé via 2778-SD (${(rates.pfo*100).toFixed(1)} %)`} value={total2CK || undefined} breakdown={bd2CK} />
<Case2042 code="2TY" label="Pertes en capital sur prêts participatifs" note="Capital non remboursé sur prêts en défaut — plateformes françaises" value={total2TY || undefined} breakdown={bd2TY} />
</div>
<div style={{ padding: '10px 14px', borderRadius: 8, background: 'rgba(99,102,241,0.05)', border: '1px solid rgba(99,102,241,0.15)', fontSize: 'var(--fs-xs)', lineHeight: 1.6, color: 'var(--text-muted)' }}>
<strong style={{ color: 'var(--text)', display: 'block', marginBottom: 4 }}>Synthèse fiscale</strong>
Les montants <BadgeTag badge={BADGE_AUTO} /> sont automatiquement reportés par la plateforme (IFU).
Les montants <BadgeTag badge={BADGE_DECL} /> nécessitent une déclaration mensuelle 2778-SD et un report manuel sur la 2042.
</div>
</div>
);
})();
return (
<div className="card">
<div style={{ display: 'flex', gap: 0, alignItems: 'flex-start' }}>
<nav style={{ width: 200, flexShrink: 0, marginRight: 20, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '8px 0', boxSizing: 'border-box' }}>
<div style={{ padding: '6px 12px 8px', fontSize: 'var(--fs-xs)', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.06em' }}>Sections</div>
<button className={`account-nav-item${view === 'matrice' ? ' active' : ''}`} onClick={() => setView('matrice')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 3h18v2H3V3zm0 7h12v2H3v-2zm0 7h18v2H3v-2z"/></svg>
Suivi mensuel
</button>
<button className={`account-nav-item${view === 'donnees' ? ' active' : ''}`} onClick={() => setView('donnees')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
Données 2042
</button>
</nav>
<div style={{ flex: 1, minWidth: 0 }}>
{view === 'matrice' && matriceView}
{view === 'donnees' && données2042View}
</div>
</div>
</div>
);
}
@@ -0,0 +1,791 @@
import { useEffect, useRef, useState } from 'react';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
const fmtEUR = n => {
if (!n && n !== 0) return '';
return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(n);
};
const fmtEURDec = n => {
if (!n && n !== 0) return '';
return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
};
/* ── Cellule du formulaire ── */
/* Layout : [code2561] [label + report 2042] [code2042 noir] [montant] */
function Cell({ label, code2561, code2042, value, filled, noReport }) {
return (
<div style={{
display: 'flex', alignItems: 'stretch',
border: '1px solid #aaa',
marginBottom: -1,
background: filled ? '#fffbe6' : '#fff',
}}>
{/* Case 1 — code 2561 (ex: KR) */}
<div style={{
width: 44, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRight: '1px solid #aaa',
background: '#f5f3ff',
fontSize: 11, fontWeight: 700, color: '#7c3aed',
}}>{code2561}</div>
{/* Case 2 — désignation + report */}
<div style={{
flex: 1, padding: '5px 8px',
fontSize: 11, color: '#222', lineHeight: 1.4,
borderRight: '1px solid #aaa',
}}>
<div>{label}</div>
<div style={{ fontSize: 10, color: noReport ? '#9a3412' : '#555', marginTop: 2 }}>
{noReport ? 'Sans report sur la déclaration 2042' : 'Montant à reporter sur votre déclaration 2042'}
</div>
</div>
{/* Case 3 — code 2042 (ex: 2TT) blanc sur noir */}
<div style={{
width: 48, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRight: '1px solid #aaa',
background: noReport ? '#78716c' : (filled ? '#111' : '#555'),
fontSize: 11, fontWeight: 700, color: '#fff',
}}>{code2042 ?? '—'}</div>
{/* Case 4 — montant */}
<div style={{
width: 90, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'flex-end',
padding: '0 10px',
fontSize: 12, fontWeight: filled ? 700 : 400,
color: filled ? '#14532d' : '#bbb',
background: filled ? '#f0fdf4' : '#fafafa',
}}>
{filled ? fmtEUR(value) + ' €' : '—'}
</div>
</div>
);
}
function SectionTitle({ children }) {
return (
<div style={{
background: '#5b21b6', color: '#fff',
WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact',
fontWeight: 700, fontSize: 11, textTransform: 'uppercase',
letterSpacing: '.04em', padding: '4px 8px',
marginTop: 10, marginBottom: 0,
}}>{children}</div>
);
}
function FieldRow({ label, code, value }) {
return (
<div style={{ display: 'flex', alignItems: 'stretch', border: '1px solid #aaa', marginBottom: -1 }}>
<div style={{ width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', textAlign: 'center', padding: '3px 4px', fontSize: 10, fontWeight: 600, color: '#7c3aed', borderRight: '1px solid #aaa', background: '#f5f3ff' }}>{code}</div>
<div style={{ width: 120, flexShrink: 0, display: 'flex', alignItems: 'center', padding: '3px 6px', fontSize: 11, color: '#222', borderRight: '1px solid #aaa' }}>{label}</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', padding: '3px 8px', fontSize: 11, color: value ? '#111' : 'transparent', background: '#fff' }}>{value || ''}</div>
</div>
);
}
/* ── Cellule éditable en place ── */
function EditableFieldRow({ label, code, value, onSave }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value ?? '');
const inputRef = useRef(null);
const startEdit = () => { setDraft(value ?? ''); setEditing(true); };
const confirm = () => { onSave(draft.trim() || null); setEditing(false); };
const onKey = e => { if (e.key === 'Enter') confirm(); if (e.key === 'Escape') setEditing(false); };
useEffect(() => { if (editing && inputRef.current) inputRef.current.focus(); }, [editing]);
return (
<div style={{ display: 'flex', alignItems: 'stretch', border: '1px solid #aaa', marginBottom: -1 }}>
<div style={{ width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3px 4px', fontSize: 10, fontWeight: 600, color: '#7c3aed', borderRight: '1px solid #aaa', background: '#f5f3ff' }}>{code}</div>
<div style={{ width: 120, flexShrink: 0, display: 'flex', alignItems: 'center', padding: '3px 6px', fontSize: 11, color: '#222', borderRight: '1px solid #aaa' }}>{label}</div>
<div
onClick={!editing ? startEdit : undefined}
style={{
flex: 1, display: 'flex', alignItems: 'center',
background: editing ? '#f5f3ff' : '#fff',
cursor: editing ? 'default' : 'text',
position: 'relative',
}}
>
{editing ? (
<>
<input
ref={inputRef}
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={onKey}
style={{
flex: 1, border: 'none', outline: 'none', background: 'transparent',
fontSize: 11, padding: '3px 8px', color: '#111',
}}
placeholder="Saisir…"
/>
<button
onClick={confirm}
title="Valider"
style={{
flexShrink: 0, border: 'none', background: 'none', cursor: 'pointer',
padding: '0 8px', color: '#7c3aed', fontSize: 14, fontWeight: 700,
}}
></button>
</>
) : (
<span style={{ padding: '3px 8px', fontSize: 11, color: value ? '#111' : '#bbb', fontStyle: value ? 'normal' : 'italic' }}>
{value || 'Cliquer pour saisir'}
</span>
)}
</div>
</div>
);
}
/* ── Formulaire d'une ligne (plateforme × investisseur) ── */
function Cerfa2561Form({ ligne, index, total }) {
const flatTax = ligne.domiciliation === 'FR' && ligne.fiscalite === 'flat_tax';
const use2TR = ligne.type_produit_fiscal === '2TR';
const hasPertes = ligne.case_2TY > 0;
const [taxDetails, setTaxDetails] = useState(null);
useEffect(() => {
api.get(`/plateforme-tax/${ligne.plateforme_id}/${ligne.annee}`)
.then(setTaxDetails)
.catch(() => setTaxDetails({ raison_sociale: ligne.plateforme_nom, siret_n: null, siret_n1: null }));
}, [ligne.plateforme_id, ligne.annee]); // eslint-disable-line
const save = (field) => (val) => {
if (!taxDetails) return;
const updated = { ...taxDetails, [field]: val };
setTaxDetails(updated);
api.patch(`/plateforme-tax/${taxDetails.id}`, { [field]: val });
};
return (
<div style={{
fontFamily: 'Arial, sans-serif',
maxWidth: 900, margin: '0 auto 40px',
border: '2px solid #5b21b6',
pageBreakAfter: index < total - 1 ? 'always' : 'auto',
colorScheme: 'light',
background: '#fff',
color: '#111',
}}>
{/* En-tête */}
<div style={{ background: '#5b21b6', color: '#fff', padding: '10px 14px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 700, letterSpacing: '.05em' }}>CERFA N°2561 N°11428*27</div>
<div style={{ fontSize: 11, marginTop: 2, opacity: .85 }}>Déclaration récapitulative des opérations sur valeurs mobilières et revenus de capitaux mobiliers</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 22, fontWeight: 700 }}>{ligne.annee}</div>
<div style={{ fontSize: 10, opacity: .8 }}>Simulation non officielle</div>
</div>
</div>
<div style={{ padding: 10 }}>
{/* Avertissement */}
<div style={{ background: '#fff8e1', border: '1px solid #f59e0b', borderRadius: 4, padding: '6px 10px', fontSize: 10, color: '#92400e', marginBottom: 10 }}>
Ce document est une simulation générée par votre outil de suivi. Il ne remplace pas le formulaire officiel émis par la plateforme.
Vérifiez les informations auprès de {ligne.plateforme_nom} avant toute déclaration.
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
{/* Colonne gauche */}
<div>
<SectionTitle>Désignation du payeur (plateforme)</SectionTitle>
<EditableFieldRow label="Raison sociale" code="ZM"
value={taxDetails?.raison_sociale ?? ligne.plateforme_nom}
onSave={save('raison_sociale')} />
<EditableFieldRow label={`N° SIRET au 31-12-${Number(ligne.annee) - 1}`} code="ZT"
value={taxDetails?.siret_n1 ?? null}
onSave={save('siret_n1')} />
<EditableFieldRow label={`N° SIRET au 31-12-${ligne.annee}`} code="ZS"
value={taxDetails?.siret_n ?? null}
onSave={save('siret_n')} />
<SectionTitle>Désignation du bénéficiaire (investisseur)</SectionTitle>
<FieldRow label="Nom de famille" code="ZC" value={ligne.investisseur_nom} />
<FieldRow label="Prénoms" code="ZD" value={ligne.investisseur_prenom} />
</div>
{/* Colonne droite — infos générales */}
<div>
<SectionTitle>Informations générales</SectionTitle>
<FieldRow label="Période de référence" code="AQ" value={`0101 1231`} />
<FieldRow label="Année fiscale" code="—" value={ligne.annee} />
<SectionTitle>Récapitulatif fiscal</SectionTitle>
<div style={{ border: '1px solid #aaa', padding: '6px 8px', background: '#f8faff', fontSize: 11 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3 }}>
<span>Intérêts bruts</span><strong>{fmtEURDec(ligne.interets_bruts)} </strong>
</div>
{flatTax && <>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3, color: '#dc2626' }}>
<span> Prélèvements sociaux</span><span>{fmtEURDec(ligne.prelev_sociaux)} </span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3, color: '#dc2626' }}>
<span> PFNL (12,8%)</span><span>{fmtEURDec(ligne.prelev_forfaitaire)} </span>
</div>
</>}
<div style={{ display: 'flex', justifyContent: 'space-between', borderTop: '1px solid #ccc', paddingTop: 3, fontWeight: 700 }}>
<span>Intérêts nets</span><span>{fmtEURDec(ligne.interets_nets)} </span>
</div>
<div style={{ marginTop: 4, fontSize: 10, color: '#666' }}>
{flatTax
? '✓ Plateforme française — PS et PFNL déjà prélevés à la source'
: '○ Plateforme étrangère — PS non prélevés, à régulariser'}
</div>
</div>
</div>
</div>
{/* Cases fiscales */}
<SectionTitle>Cases à remplir sur le formulaire 2561</SectionTitle>
{/* Section produits — 2TT ou 2TR selon paramétrage plateforme */}
{use2TR ? (
<>
<div style={{ background: '#ede9fe', padding: '4px 8px', fontSize: 10, fontWeight: 700, color: '#6d28d9', borderBottom: '1px solid #aaa', marginTop: 0 }}>
PRODUITS DE PLACEMENT À REVENU FIXE
</div>
<Cell
label="Gains — produits de placement à revenu fixe"
code2561="AR" code2042="2TR" value={ligne.case_2TR} filled={ligne.case_2TR > 0}
/>
</>
) : (
<>
<div style={{ background: '#ede9fe', padding: '4px 8px', fontSize: 10, fontWeight: 700, color: '#6d28d9', borderBottom: '1px solid #aaa', marginTop: 0 }}>
PRODUITS DES MINIBONS ET DES PRÊTS DANS LE CADRE DU FINANCEMENT PARTICIPATIF
</div>
<Cell
label="Produits des prêts dans le cadre du financement participatif"
code2561="KR" code2042="2TT" value={ligne.case_2TT} filled={ligne.case_2TT > 0}
/>
</>
)}
{use2TR ? (
<Cell
label="Pertes — produits de placement à revenu fixe"
code2561="AS" code2042={null} value={ligne.case_2TY} filled={ligne.case_2TY > 0}
noReport
/>
) : (
<Cell
label="Pertes sur prêts dans le cadre du financement participatif"
code2561="KS" code2042="2TY" value={ligne.case_2TY} filled={ligne.case_2TY > 0}
/>
)}
{/* Section 2BH — si PS ont été prélevés */}
{ligne.case_2BH > 0 && <>
<div style={{ background: '#ede9fe', padding: '4px 8px', fontSize: 10, fontWeight: 700, color: '#5b21b6', borderBottom: '1px solid #aaa', marginTop: 6 }}>
PRODUITS POUR LESQUELS LES PRÉLÈVEMENTS SOCIAUX ONT DÉJÀ ÉTÉ APPLIQUÉS
</div>
<Cell
label="Produits susceptibles d'ouvrir droit à CSG déductible en cas d'option pour le barème progressif"
code2561="DQ" code2042="2BH" value={ligne.case_2BH} filled={ligne.case_2BH > 0}
/>
</>}
{/* Section 2CK — flat-tax FR uniquement */}
{flatTax && ligne.case_2CK > 0 && <>
<div style={{ background: '#fef9c3', padding: '4px 8px', fontSize: 10, fontWeight: 700, color: '#854d0e', borderBottom: '1px solid #aaa', marginTop: 6 }}>
CRÉDIT D'IMPÔT PRÉLÈVEMENT
</div>
<Cell
label="Crédit d'impôt prélèvement PFNL déjà versé (12,8%)"
code2561="AD" code2042="2CK" value={ligne.case_2CK} filled={ligne.case_2CK > 0}
/>
</>}
{/* Nombre d'opérations — cliquable */}
<RembDetail ligne={ligne} />
</div>
</div>
);
}
/* ── Panneau remboursements dépliable ── */
function RembDetail({ ligne }) {
const [open, setOpen] = useState(false);
const [rows, setRows] = useState(null);
const [loading, setLoading] = useState(false);
const { activeView } = useInvestisseur();
const load = () => {
if (rows !== null) { setOpen(o => !o); return; }
setLoading(true);
const params = {
annee: ligne.annee,
plateforme_id: ligne.plateforme_id,
investisseur_id: ligne.investisseur_id,
...(activeView === 'all' ? { scope: 'all' } : {}),
};
api.get('/taxreport/cerfa2561/remboursements', params)
.then(data => { setRows(data); setOpen(true); })
.finally(() => setLoading(false));
};
const fmtDate = d => d ? d.slice(0, 10) : '—';
return (
<>
<div
onClick={load}
style={{
marginTop: 8, fontSize: 10, textAlign: 'right',
color: '#7c3aed', cursor: 'pointer', userSelect: 'none',
display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 4,
}}
>
{loading ? 'Chargement…' : (
<>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
{ligne.nb_remboursements} remboursement{ligne.nb_remboursements > 1 ? 's' : ''} pris en compte
</>
)}
</div>
{open && rows && (
<div className="no-print" style={{ marginTop: 6, border: '1px solid #ccc', borderRadius: 4, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 10 }}>
<thead>
<tr style={{ background: '#5b21b6' }}>
{[['Date','left'],['Projet','left'],['Capital','right'],['Intérêts bruts','right'],['Prélèv. sociaux','right'],['PFNL (2CK)','right'],['Intérêts nets','right']].map(([label, align]) => (
<th key={label} style={{ padding: '4px 8px', textAlign: align, fontWeight: 600, color: '#fff' }}>{label}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={r.id} style={{ background: i % 2 === 0 ? '#fff' : '#f8faff', borderBottom: '1px solid #e5e7eb' }}>
<td style={{ padding: '3px 8px', whiteSpace: 'nowrap' }}>{fmtDate(r.date_remb)}</td>
<td style={{ padding: '3px 8px', color: '#444' }}>{r.nom_projet}</td>
<td style={{ padding: '3px 8px', textAlign: 'right' }}>{fmtEURDec(r.capital)} €</td>
<td style={{ padding: '3px 8px', textAlign: 'right', fontWeight: 600 }}>{fmtEURDec(r.interets_bruts)} €</td>
<td style={{ padding: '3px 8px', textAlign: 'right', color: '#dc2626' }}>{r.prelev_sociaux ? `${fmtEURDec(r.prelev_sociaux)} €` : '—'}</td>
<td style={{ padding: '3px 8px', textAlign: 'right', color: '#dc2626' }}>{r.prelev_forfaitaire ? `${fmtEURDec(r.prelev_forfaitaire)} €` : '—'}</td>
<td style={{ padding: '3px 8px', textAlign: 'right', fontWeight: 600, color: '#14532d' }}>{fmtEURDec(r.interets_nets)} €</td>
</tr>
))}
</tbody>
<tfoot>
<tr style={{ background: '#f5f3ff', fontWeight: 700, borderTop: '2px solid #5b21b6' }}>
<td colSpan={3} style={{ padding: '4px 8px', fontSize: 10 }}>Total</td>
<td style={{ padding: '4px 8px', textAlign: 'right' }}>{fmtEURDec(rows.reduce((s, r) => s + r.interets_bruts, 0))} €</td>
<td style={{ padding: '4px 8px', textAlign: 'right', color: '#dc2626' }}>{fmtEURDec(rows.reduce((s, r) => s + r.prelev_sociaux, 0))} €</td>
<td style={{ padding: '4px 8px', textAlign: 'right', color: '#dc2626' }}>{fmtEURDec(rows.reduce((s, r) => s + r.prelev_forfaitaire, 0))} €</td>
<td style={{ padding: '4px 8px', textAlign: 'right', color: '#14532d' }}>{fmtEURDec(rows.reduce((s, r) => s + r.interets_nets, 0))} €</td>
</tr>
</tfoot>
</table>
</div>
)}
</>
);
}
const MOIS_LABELS = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
const MOIS_NUMS = ['01','02','03','04','05','06','07','08','09','10','11','12'];
const r2 = v => Math.round((v ?? 0) * 100) / 100;
/* ── Report 2042 pour plateformes françaises ── */
function Report2042Block2561({ lignes }) {
const frLignes = (lignes ?? []).filter(l => l.domiciliation === 'FR');
if (frLignes.length === 0) return (
<div className="card"><p className="text-muted" style={{ margin: 0 }}>Aucune plateforme française avec des remboursements.</p></div>
);
const total2TT = frLignes.reduce((s, l) => s + (l.case_2TT ?? 0), 0);
const total2TR = frLignes.reduce((s, l) => s + (l.case_2TR ?? 0), 0);
const total2BH = frLignes.reduce((s, l) => s + (l.case_2BH ?? 0), 0);
const total2CK = frLignes.reduce((s, l) => s + (l.case_2CK ?? 0), 0);
const total2TY = frLignes.reduce((s, l) => s + (l.case_2TY ?? 0), 0);
const fmt = v => Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
const fullName = l => { const n = l.investisseur_nom ?? ''; const p = l.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); };
const Case2042 = ({ code, label, note, value, breakdown }) => {
if (!value) return null;
return (
<div style={{ borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'grid', gridTemplateColumns: '90px 1fr auto', gap: '0 12px', alignItems: 'center', padding: '10px 14px' }}>
<div style={{ fontFamily: 'monospace', fontWeight: 800, fontSize: 'var(--fs-base)', color: 'var(--primary)', background: 'rgba(99,102,241,0.07)', borderRadius: 6, padding: '2px 6px', textAlign: 'center', letterSpacing: '.04em' }}>{code}</div>
<div>
<div style={{ fontSize: 'var(--fs-sm)', fontWeight: 600 }}>{label}</div>
{note && <div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 2 }}>{note}</div>}
</div>
<div style={{ fontWeight: 800, fontSize: 'var(--fs-lg)', whiteSpace: 'nowrap', color: 'var(--text)' }}>{fmt(value)} €</div>
</div>
{breakdown && breakdown.length > 0 && (
<div style={{ padding: '0 14px 10px 118px', display: 'flex', flexDirection: 'column', gap: 3 }}>
{breakdown.map(p => (
<div key={p.nom} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
<span>└ {p.nom}</span>
<span style={{ fontWeight: 500 }}>{fmt(p.val)} €</span>
</div>
))}
</div>
)}
</div>
);
};
const annee = frLignes[0]?.annee;
return (
<div className="card">
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 14 }}>
<h4 style={{ margin: 0, fontWeight: 700 }}>Report annuel — Déclaration 2042</h4>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Revenus {annee} à reporter sur la 2042 déposée en {Number(annee) + 1}</span>
</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden', marginBottom: 14 }}>
<Case2042
code="2TT"
label="Produits des prêts participatifs (financement participatif)"
note="Intérêts bruts plateformes françaises avec case KR (2TT)"
value={total2TT}
breakdown={frLignes.filter(l => l.case_2TT > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2TT }))}
/>
<Case2042
code="2TR"
label="Produits de placement à revenu fixe"
note="Intérêts bruts plateformes françaises avec case AR (2TR)"
value={total2TR}
breakdown={frLignes.filter(l => l.case_2TR > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2TR }))}
/>
<Case2042
code="2BH"
label="Produits pour lesquels les prélèvements sociaux ont déjà été appliqués"
note="Même montant que 2TT/2TR évite la double imposition aux prélèvements sociaux"
value={total2BH}
breakdown={frLignes.filter(l => l.case_2BH > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2BH }))}
/>
<Case2042
code="2CK"
label="Crédit d'impôt — PFNL déjà versé (12,8 %)"
note="Prélèvement forfaitaire non libératoire déjà retenu à la source — s'impute sur l'IR définitif"
value={total2CK}
breakdown={frLignes.filter(l => l.case_2CK > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2CK }))}
/>
<Case2042
code="2TY"
label="Pertes en capital sur prêts participatifs"
note="Capital non remboursé sur prêts en défaut ou clôturés"
value={total2TY}
breakdown={frLignes.filter(l => l.case_2TY > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2TY }))}
/>
</div>
<div style={{ padding: '10px 14px', borderRadius: 8, background: 'rgba(99,102,241,0.05)', border: '1px solid rgba(99,102,241,0.15)', fontSize: 'var(--fs-xs)', lineHeight: 1.6, color: 'var(--text-muted)' }}>
<strong style={{ color: 'var(--text)', display: 'block', marginBottom: 4 }}>Comment fonctionne le PFU sur les plateformes françaises ?</strong>
Les plateformes françaises soumises à la Flat Tax retiennent à la source les prélèvements sociaux (17,2 %) et l'impôt forfaitaire (12,8 %).
Le montant 2CK correspond à l'acompte IR déjà versé — il s'impute sur l'impôt définitif calculé lors de votre 2042.
Le montant 2BH est déclaré en sus de 2TT/2TR pour neutraliser les prélèvements sociaux déjà prélevés.
</div>
</div>
);
}
/* ── Composant principal ── */
export default function Cerfa2561Preview({ annee, activeView, onClose, inline = false, expanded = false, onToggleExpand }) {
const { activeId } = useInvestisseur();
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [filterPlat, setFilterPlat] = useState('all');
const [view, setView] = useState('matrice');
const contentRef = useRef(null);
const handlePrint = () => {
const content = contentRef.current;
if (!content) return;
const platName = data?.lignes?.find(l => `${l.plateforme_id}_${l.investisseur_id}` === filterPlat)?.plateforme_nom ?? 'Plateforme';
const printWin = window.open('', '_blank', 'width=900,height=700');
printWin.document.write(`<!DOCTYPE html><html><head>
<meta charset="utf-8"/>
<title>CERFA 2561 — ${annee} — ${platName}</title>
<style>
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
body { margin: 0; padding: 20px; font-family: Arial, sans-serif; background: white; color: #111; }
@media print { body { padding: 0; } }
.no-print { display: none !important; }
</style>
</head><body>${content.innerHTML}</body></html>`);
printWin.document.close();
printWin.focus();
setTimeout(() => { printWin.print(); printWin.close(); }, 400);
};
useEffect(() => {
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
api.get('/taxreport/cerfa2561', { annee, ...scopeParams })
.then(d => {
setData(d);
const firstFr = d.lignes.find(l => l.domiciliation === 'FR') ?? d.lignes[0];
if (firstFr) setFilterPlat(`${firstFr.plateforme_id}_${firstFr.investisseur_id}`);
})
.finally(() => setLoading(false));
}, [annee, activeView]); // eslint-disable-line
const frLignes = (data?.lignes ?? []).filter(l => l.domiciliation === 'FR');
/* ── Tableau mensuel ── */
const matriceView = (
<div style={{ overflowX: 'auto' }}>
{loading && <div className="card text-muted">Chargement…</div>}
{!loading && frLignes.length === 0 && (
<div className="card"><p className="text-muted" style={{ margin: 0 }}>Aucune plateforme française avec des remboursements pour {annee}.</p></div>
)}
{!loading && frLignes.length > 0 && (
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, padding: '14px 14px 10px' }}>
<h4 style={{ margin: 0, fontWeight: 700 }}>Suivi mensuel des intérêts bruts</h4>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Revenus {annee} — plateformes françaises</span>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--fs-xs)' }}>
<thead>
<tr style={{ background: 'var(--surface-2)', borderBottom: '1px solid var(--border)' }}>
<th style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 600, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Plateforme — Détenteur</th>
{MOIS_LABELS.map(m => (
<th key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 600, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>{m}</th>
))}
<th style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 600, color: 'var(--text-muted)' }}>Total</th>
</tr>
</thead>
<tbody>
{frLignes.map((l, idx) => {
const total = r2(Object.values(l.mois ?? {}).reduce((s, m) => s + (m.interets_bruts ?? 0), 0));
return (
<tr key={`${l.plateforme_id}_${l.investisseur_id}`} style={{ borderBottom: '1px solid var(--border)', background: 'transparent' }}>
<td style={{ padding: '6px 12px', fontWeight: 500, whiteSpace: 'nowrap' }}>
{l.plateforme_nom}
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginLeft: 6 }}>— {(() => { const n = l.investisseur_nom ?? ''; const p = l.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); })()}</span>
</td>
{MOIS_NUMS.map(m => {
const v = l.mois?.[m]?.interets_bruts ?? 0;
return (
<td key={m} style={{ padding: '6px 8px', textAlign: 'right', color: v > 0 ? 'var(--text)' : 'var(--text-muted)' }}>
{v > 0 ? v.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ' : ''}
</td>
);
})}
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700 }}>
{total > 0 ? total.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ' : ''}
</td>
</tr>
);
})}
</tbody>
<tfoot>
{/* Total intérêt brut */}
<tr style={{ borderTop: '2px solid var(--border)', background: 'var(--surface-2)' }}>
<td style={{ padding: '6px 12px', fontWeight: 700 }}>Total intérêt brut</td>
{MOIS_NUMS.map(m => {
const v = r2(frLignes.reduce((s, l) => s + (l.mois?.[m]?.interets_bruts ?? 0), 0));
return (
<td key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 700, color: v > 0 ? 'var(--text)' : 'var(--text-muted)' }}>
{v > 0 ? v.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : ''}
</td>
);
})}
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700 }}>
{r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + (m.interets_bruts ?? 0), 0)), 0)).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €
</td>
</tr>
{/* Taux prélevé */}
<tr style={{ background: 'transparent' }}>
<td style={{ padding: '4px 12px', color: 'var(--text-muted)', fontSize: 'var(--fs-xs)' }}>
Taux total prélevé ({(() => {
const totalBrut = r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + (m.interets_bruts ?? 0), 0)), 0));
const totalPrelev = r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + ((m.prelev_sociaux ?? 0) + (m.prelev_forfaitaire ?? 0)), 0)), 0));
return totalBrut > 0 ? (totalPrelev / totalBrut * 100).toFixed(1) : '';
})()} %)
</td>
{MOIS_NUMS.map(m => {
const brut = r2(frLignes.reduce((s, l) => s + (l.mois?.[m]?.interets_bruts ?? 0), 0));
const prelev = r2(frLignes.reduce((s, l) => s + ((l.mois?.[m]?.prelev_sociaux ?? 0) + (l.mois?.[m]?.prelev_forfaitaire ?? 0)), 0));
const taux = brut > 0 ? (prelev / brut * 100).toFixed(1) + ' %' : '';
return (
<td key={m} style={{ padding: '4px 8px', textAlign: 'right', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
{taux}
</td>
);
})}
<td style={{ padding: '4px 12px', textAlign: 'right', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
{(() => {
const totalBrut = r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + (m.interets_bruts ?? 0), 0)), 0));
const totalPrelev = r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + ((m.prelev_sociaux ?? 0) + (m.prelev_forfaitaire ?? 0)), 0)), 0));
return totalBrut > 0 ? (totalPrelev / totalBrut * 100).toFixed(1) + ' %' : '';
})()}
</td>
</tr>
{/* Total intérêt net */}
<tr style={{ background: 'rgba(22,163,74,0.06)', borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '6px 12px', fontWeight: 700, color: 'var(--success)' }}>Total intérêt net</td>
{MOIS_NUMS.map(m => {
const net = r2(frLignes.reduce((s, l) => {
const mo = l.mois?.[m];
return s + ((mo?.interets_bruts ?? 0) - (mo?.prelev_sociaux ?? 0) - (mo?.prelev_forfaitaire ?? 0));
}, 0));
return (
<td key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 700, color: net > 0 ? 'var(--success)' : 'var(--text-muted)' }}>
{net > 0 ? net.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : ''}
</td>
);
})}
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700, color: 'var(--success)' }}>
{r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + ((m.interets_bruts ?? 0) - (m.prelev_sociaux ?? 0) - (m.prelev_forfaitaire ?? 0)), 0)), 0)).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €
</td>
</tr>
{/* Note de bas de tableau */}
<tr style={{ background: 'var(--surface-2)' }}>
<td colSpan={14} style={{ padding: '8px 12px', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', borderTop: '1px solid var(--border)' }}>
Taux {annee} : PFO 12,8 % + CSG 10,6 % + CRDS 0,5 % + Solidarité 7,5 % = 31,4 % &nbsp;·&nbsp; Prélèvements effectués à la source par les plateformes &nbsp;·&nbsp; Report automatique sur la déclaration de revenus 2042
</td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
);
/* ── Barre outils (vue cerfa) ── */
const toolbar = (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
{data && frLignes.length > 0 && (
<select
value={filterPlat}
onChange={e => setFilterPlat(e.target.value)}
style={{ fontSize: 'var(--fs-sm)', padding: '4px 8px', width: 240 }}
>
{frLignes.map(l => (
<option key={`${l.plateforme_id}_${l.investisseur_id}`} value={`${l.plateforme_id}_${l.investisseur_id}`}>
{l.plateforme_nom} — {(() => { const n = l.investisseur_nom ?? ''; const p = l.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); })()}
</option>
))}
</select>
)}
<div style={{ flex: 1 }} />
{inline && onToggleExpand && (
<button className="icon-btn" onClick={() => onToggleExpand()} title={expanded ? 'Réduire' : 'Agrandir'}>
{expanded ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/>
<line x1="10" y1="14" x2="3" y2="21"/><line x1="21" y1="3" x2="14" y2="10"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
)}
</button>
)}
<button className="icon-btn" onClick={handlePrint} disabled={loading || !frLignes.length} title="Imprimer / Enregistrer en PDF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 6 2 18 2 18 9"/>
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/>
<rect x="6" y="14" width="12" height="8"/>
</svg>
</button>
{!inline && (
<button className="icon-btn" onClick={onClose} title="Fermer">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
)}
</div>
);
/* ── Vue cerfa ── */
const cerfaView = (
<div ref={contentRef}>
{toolbar}
{loading && <div style={{ textAlign: 'center', color: '#666', paddingTop: 20 }}>Chargement…</div>}
{!loading && frLignes.length === 0 && (
<div style={{ textAlign: 'center', color: '#666', paddingTop: 20 }}>Aucune plateforme française avec des remboursements pour {annee}.</div>
)}
{!loading && data?.lignes
?.filter(l => `${l.plateforme_id}_${l.investisseur_id}` === filterPlat)
.map((ligne, i, arr) => (
<Cerfa2561Form key={`${ligne.plateforme_id}_${ligne.investisseur_id}`} ligne={ligne} index={i} total={arr.length} />
))}
</div>
);
if (inline) {
return (
<div className="card">
<div style={{ display: 'flex', gap: 0, alignItems: 'flex-start' }}>
{/* Sidebar nav */}
<nav style={{ width: 200, flexShrink: 0, marginRight: 20, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '8px 0', boxSizing: 'border-box' }}>
<div style={{ padding: '6px 12px 8px', fontSize: 'var(--fs-xs)', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.06em' }}>
Sections
</div>
<button className={`account-nav-item${view === 'matrice' ? ' active' : ''}`} onClick={() => setView('matrice')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 3h18v2H3V3zm0 7h12v2H3v-2zm0 7h18v2H3v-2z"/>
</svg>
Suivi mensuel
</button>
<button className={`account-nav-item${view === 'cerfa' ? ' active' : ''}`} onClick={() => setView('cerfa')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
Données 2561
</button>
<button className={`account-nav-item${view === 'report2042' ? ' active' : ''}`} onClick={() => setView('report2042')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
Report 2042
</button>
</nav>
{/* Contenu */}
<div style={{ flex: 1, minWidth: 0 }}>
{view === 'matrice' && matriceView}
{view === 'cerfa' && cerfaView}
{view === 'report2042' && <Report2042Block2561 lignes={data?.lignes} />}
</div>
</div>
</div>
);
}
/* ── Vue modale (non-inline) ── */
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.6)', display: 'flex', flexDirection: 'column' }}>
<div className="no-print" style={{ background: 'var(--surface)', borderBottom: '1px solid var(--border)', padding: '8px 16px', flexShrink: 0 }}>
{toolbar}
</div>
<div ref={contentRef} style={{ flex: 1, overflowY: 'auto', padding: '24px 20px', background: '#e5e7eb' }}>
{loading && <div style={{ textAlign: 'center', color: '#666', paddingTop: 40 }}>Chargement…</div>}
{!loading && data?.lignes?.length === 0 && (
<div style={{ textAlign: 'center', color: '#666', paddingTop: 40 }}>Aucun remboursement trouvé pour {annee}.</div>
)}
{!loading && data?.lignes
?.filter(l => `${l.plateforme_id}_${l.investisseur_id}` === filterPlat)
.map((ligne, i, arr) => (
<Cerfa2561Form key={`${ligne.plateforme_id}_${ligne.investisseur_id}`} ligne={ligne} index={i} total={arr.length} />
))}
</div>
</div>
);
}
@@ -0,0 +1,621 @@
import { useEffect, useState } from 'react';
import { api } from '../api.js';
const MOIS_LABELS = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
const MOIS_NUMS = ['01','02','03','04','05','06','07','08','09','10','11','12'];
// Taux par défaut (2026+) — remplacés par les données de /api/pfu
const DEFAULT_RATES = { pfo: 0.128, csg: 0.106, crds: 0.005, solidarite: 0.075 };
function getRatesForYear(annee, pfuList) {
const yr = Number(annee);
// Chercher l'année exacte, puis l'année précédente la plus proche
const sorted = [...pfuList].sort((a, b) => b.annee - a.annee);
const match = sorted.find(r => r.annee <= yr);
if (!match) return DEFAULT_RATES;
return {
pfo: (match.impot_revenu ?? 12.8) / 100,
csg: (match.csg ?? 9.2) / 100,
crds: (match.crds ?? 0.5) / 100,
solidarite: (match.solidarite ?? 7.5) / 100,
};
}
const r = v => Math.round((v ?? 0) * 100) / 100;
const fmtEUR = v => {
if (v == null || v === 0) return <span style={{ color: 'var(--text-muted)' }}>- </span>;
return `${Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`;
};
const fmtInt = v => {
if (v == null || v === 0) return <span style={{ color: 'var(--text-muted)' }}>-</span>;
return `${v}`;
};
/* ── Simulation cases CERFA pour un montant brut ── */
function computeCases(ba, rates) {
const R = rates ?? DEFAULT_RATES;
const BA = Math.round(ba);
const IA = Math.round(BA * R.pfo);
const PQ = Math.round(BA * R.csg);
const PV = Math.round(BA * R.crds);
const PF1 = PQ + PV;
const PG1 = Math.round(BA * R.solidarite);
const PU = PF1;
const PK = PG1;
const QR = IA + PU + PK;
const totalTax = R.pfo + R.csg + R.crds + R.solidarite;
return { BA, IA, PQ, PV, PF1, PG1, PU, PK, QR, totalTax, pfo: R.pfo, csgRate: R.csg, crdsRate: R.crds, solidRate: R.solidarite };
}
/* ── Composant principal ── */
export default function Cerfa2778Preview({ annee, activeView }) {
const LS_KEY = 'cl_2778_excluded_plats';
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [pfuList, setPfuList] = useState([]);
const [excluded, setExcluded] = useState(() => {
try { return new Set(JSON.parse(localStorage.getItem(LS_KEY)) ?? []); }
catch { return new Set(); }
});
const [selectedMois, setSelectedMois] = useState(null);
const [view, setView] = useState('matrice'); // 'matrice' | 'cerfa'
useEffect(() => {
setLoading(true);
setData(null);
setSelectedMois(null);
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
Promise.all([
api.get('/taxreport/2778', { annee, ...scopeParams }),
pfuList.length === 0 ? api.get('/pfu') : Promise.resolve(null),
]).then(([d, pfu]) => {
setData(d);
if (pfu) setPfuList(pfu);
}).then(([d]) => {
// Auto-sélection du dernier mois avec données si vue cerfa active
if (d?.plateformes) {
const totaux = ['01','02','03','04','05','06','07','08','09','10','11','12'].map(m => {
const stored = JSON.parse(localStorage.getItem('cl_2778_excluded_plats') ?? '[]');
const excl = new Set(stored);
return d.plateformes.filter(p => !excl.has(p.id)).reduce((s, p) => s + (p.mois[m] ?? 0), 0);
});
const last = totaux.reduce((idx, val, i) => val > 0 ? i : idx, null);
if (last !== null) setSelectedMois(last);
}
}).finally(() => setLoading(false));
}, [annee, activeView]); // eslint-disable-line
if (loading || !data) return <div className="card text-muted">Chargement</div>;
const plateformes = data.plateformes;
const rates = getRatesForYear(annee, pfuList);
const TOTAL_TAX = rates.pfo + rates.csg + rates.crds + rates.solidarite;
if (plateformes.length === 0) {
return (
<div className="card">
<p className="text-muted" style={{ margin: 0 }}>
Aucun remboursement de plateforme étrangère pour {annee}.
</p>
</div>
);
}
/* ── Auto-sélection du dernier mois avec données ── */
// (calculé après le rendu initial)
/* ── Totaux mensuels des plateformes incluses ── */
const totauxMois = MOIS_NUMS.map(m => {
let sum = 0;
for (const plat of plateformes) {
if (!excluded.has(plat.id)) sum += plat.mois[m] ?? 0;
}
return r(sum);
});
const lastMoisWithData = totauxMois.reduce((last, val, i) => val > 0 ? i : last, null);
/* ── Données du mois sélectionné pour simulation ── */
const moisData = selectedMois !== null ? (() => {
const mNum = MOIS_NUMS[selectedMois];
const ba = totauxMois[selectedMois];
const detail = plateformes
.filter(p => !excluded.has(p.id) && (p.mois[mNum] ?? 0) > 0)
.map(p => ({ nom: p.nom, montant: p.mois[mNum] }));
return { ba, detail, cases: computeCases(ba, rates) };
})() : null;
/* ── Toggle plateforme + persistance localStorage ── */
const togglePlat = (id) => {
setExcluded(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
localStorage.setItem(LS_KEY, JSON.stringify([...next]));
return next;
});
};
const cellStyle = {
textAlign: 'right',
padding: '6px 10px',
fontSize: 'var(--fs-sm)',
whiteSpace: 'nowrap',
borderBottom: '1px solid var(--border)',
};
const headStyle = {
textAlign: 'center',
padding: '6px 10px',
fontSize: 'var(--fs-xs)',
fontWeight: 600,
color: 'var(--text-muted)',
background: 'var(--surface-2)',
borderBottom: '1px solid var(--border)',
whiteSpace: 'nowrap',
};
return (
<div className="card">
{/* ── Navigation sidebar ── */}
<div style={{ display: 'flex', gap: 0, alignItems: 'flex-start' }}>
<nav style={{
width: 200, flexShrink: 0, marginRight: 20,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 10, padding: '8px 0', boxSizing: 'border-box',
}}>
<div style={{ padding: '6px 12px 8px', fontSize: 'var(--fs-xs)', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.06em' }}>
Sections
</div>
<button
className={`account-nav-item${view === 'matrice' ? ' active' : ''}`}
onClick={() => { setView('matrice'); }}
>
<svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='1.8' strokeLinecap='round' strokeLinejoin='round'>
<path d='M3 3h18v2H3V3zm0 7h12v2H3v-2zm0 7h18v2H3v-2z'/>
</svg>
Suivi mensuel
</button>
<button
className={`account-nav-item${view === 'cerfa' ? ' active' : ''}`}
onClick={() => { setView('cerfa'); if (selectedMois === null) setSelectedMois(lastMoisWithData); }}
>
<svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='1.8' strokeLinecap='round' strokeLinejoin='round'>
<path d='M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'/>
</svg>
Données 2778-SD
</button>
<button
className={`account-nav-item${view === 'report2042' ? ' active' : ''}`}
onClick={() => { setView('report2042'); }}
>
<svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='1.8' strokeLinecap='round' strokeLinejoin='round'>
<path d='M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z'/>
</svg>
Report 2042
</button>
<div style={{ margin: '10px 12px 6px', borderTop: '1px solid var(--border)' }} />
<div style={{ padding: '4px 12px', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', lineHeight: 1.5 }}>
Plateformes non-françaises<br/>Base = intérêts bruts avant retenue locale
</div>
</nav>
<div style={{ flex: 1, minWidth: 0 }}>
{/* ── Vue Matrice ── */}
{view === 'matrice' && (
<>
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, padding: '14px 14px 10px' }}>
<h4 style={{ margin: 0, fontWeight: 700 }}>Suivi mensuel des intérêts bruts</h4>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Revenus {annee} plateformes étrangères</span>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 900 }}>
<thead>
<tr>
<th style={{ ...headStyle, textAlign: 'left', minWidth: 180, position: 'sticky', left: 0, background: 'var(--surface-2)' }}>
Plateforme Détenteur
</th>
{MOIS_LABELS.map((m, i) => (
<th key={m} style={headStyle}>{m}</th>
))}
<th style={{ ...headStyle, background: 'var(--surface-2)' }}>Total</th>
</tr>
</thead>
<tbody>
{plateformes.map(plat => {
const isExcluded = excluded.has(plat.id);
const total = Object.values(plat.mois).reduce((a, b) => a + b, 0);
return (
<tr key={plat.id} style={{ opacity: isExcluded ? 0.4 : 1, transition: 'opacity .15s' }}>
<td style={{
...cellStyle, textAlign: 'left',
position: 'sticky', left: 0,
background: 'var(--surface)',
fontWeight: 500,
}}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none', fontSize: 'var(--fs-sm)', textTransform: 'none', letterSpacing: 'normal', color: 'var(--text)', fontWeight: 400, marginBottom: 0 }}>
<input
type="checkbox"
checked={!isExcluded}
onChange={() => togglePlat(plat.id)}
style={{ accentColor: 'var(--primary)', width: 14, height: 14, flexShrink: 0 }}
/>
<span style={{ fontWeight: 500 }}>
{plat.nom}
{(plat.investisseur_nom || plat.investisseur_prenom) && (
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginLeft: 6, fontWeight: 400 }}>
{(() => { const n = plat.investisseur_nom ?? ''; const p = plat.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); })()}
</span>
)}
</span>
</label>
</td>
{MOIS_NUMS.map(m => (
<td key={m} style={cellStyle}>{fmtEUR(plat.mois[m] ?? null)}</td>
))}
<td style={{ ...cellStyle, fontWeight: 600 }}>{fmtEUR(r(total))}</td>
</tr>
);
})}
{/* ── Séparateur ── */}
<tr><td colSpan={14} style={{ height: 4, background: 'var(--surface-2)' }} /></tr>
{/* ── Total brut mensuel ── */}
<tr style={{ background: 'var(--surface-2)' }}>
<td style={{ ...cellStyle, textAlign: 'left', fontWeight: 600, position: 'sticky', left: 0, background: 'var(--surface-2)' }}>
Total intérêt brut
</td>
{totauxMois.map((t, i) => (
<td key={i} style={{ ...cellStyle, fontWeight: 600 }}>{fmtEUR(t || null)}</td>
))}
<td style={{ ...cellStyle, fontWeight: 700 }}>
{fmtEUR(r(totauxMois.reduce((a, b) => a + b, 0)))}
</td>
</tr>
{/* ── Taux Flat Tax ── */}
<tr>
<td style={{ ...cellStyle, textAlign: 'left', color: 'var(--text-muted)', position: 'sticky', left: 0, background: 'var(--surface)' }}>
Taux total prélevé ({(TOTAL_TAX * 100).toFixed(1)} %)
</td>
{MOIS_NUMS.map((_, i) => (
<td key={i} style={{ ...cellStyle, color: 'var(--text-muted)' }}>
{totauxMois[i] > 0 ? `${(TOTAL_TAX * 100).toFixed(2).replace('.', ',')} %` : '—'}
</td>
))}
<td style={{ ...cellStyle, color: 'var(--text-muted)' }}>{`${(TOTAL_TAX * 100).toFixed(2).replace('.', ',')} %`}</td>
</tr>
{/* ── Total Flat Tax ── */}
<tr style={{ background: 'rgba(239,68,68,0.06)' }}>
<td style={{ ...cellStyle, textAlign: 'left', fontWeight: 600, color: 'var(--danger)', position: 'sticky', left: 0, background: 'rgba(239,68,68,0.06)' }}>
Montant total à payer (QR)
</td>
{totauxMois.map((t, i) => {
const { QR } = computeCases(t, rates);
return (
<td key={i} style={{ ...cellStyle, fontWeight: 600, color: t > 0 ? 'var(--danger)' : undefined }}>
{t > 0 ? fmtInt(QR) : <span style={{ color: 'var(--text-muted)' }}>-</span>}
</td>
);
})}
<td style={{ ...cellStyle, fontWeight: 700, color: 'var(--danger)' }}>
{fmtInt(computeCases(r(totauxMois.reduce((a, b) => a + b, 0)), rates).QR)}
</td>
</tr>
{/* ── Intérêt net ── */}
<tr style={{ background: 'rgba(16,185,129,0.05)' }}>
<td style={{ ...cellStyle, textAlign: 'left', fontWeight: 600, color: 'var(--success)', position: 'sticky', left: 0, background: 'rgba(16,185,129,0.05)' }}>
Total intérêt net
</td>
{totauxMois.map((t, i) => (
<td key={i} style={{ ...cellStyle, fontWeight: 600, color: t > 0 ? 'var(--success)' : undefined }}>
{fmtEUR(t > 0 ? r(t * (1 - TOTAL_TAX)) : null)}
</td>
))}
<td style={{ ...cellStyle, fontWeight: 700, color: 'var(--success)' }}>
{fmtEUR(r(totauxMois.reduce((a, b) => a + b, 0) * (1 - TOTAL_TAX)))}
</td>
</tr>
</tbody>
</table>
<div style={{ padding: '10px 16px', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', borderTop: '1px solid var(--border)', display: 'flex', gap: 24, flexWrap: 'wrap' }}>
<span>Taux {annee} : PFO {(rates.pfo*100).toFixed(1)} % + CSG {(rates.csg*100).toFixed(1)} % + CRDS {(rates.crds*100).toFixed(1)} % + Solidarité {(rates.solidarite*100).toFixed(1)} % = {(TOTAL_TAX*100).toFixed(1)} %</span>
<span>·</span>
<span> Déclaration et paiement dus dans les <strong>15 premiers jours du mois suivant</strong> l'encaissement</span>
<span>·</span>
<span>Base imposable : intérêts bruts <em>après</em> déduction de l'impôt prélevé à la source à l'étranger, <em>avant</em> déduction retenue "directive épargne"</span>
</div>
</div>
</>
)}
{/* ── Vue Report 2042 ── */}
{view === 'report2042' && (
<Report2042Block annee={annee} totauxMois={totauxMois} rates={rates} plateformes={plateformes} excluded={excluded} />
)}
{/* ── Vue Données 2778-SD ── */}
{view === 'cerfa' && (
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'flex-start' }}>
{/* Sélecteur de mois */}
<div className="card" style={{ minWidth: 200, flexShrink: 0 }}>
<h4 style={{ margin: '0 0 12px', fontSize: 'var(--fs-sm)', fontWeight: 600 }}>Mois</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{MOIS_LABELS.map((label, i) => {
const ba = totauxMois[i];
const isActive = selectedMois === i;
return (
<button
key={i}
onClick={() => setSelectedMois(i)}
style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '7px 12px', borderRadius: 6, border: 'none',
background: isActive ? 'var(--primary)' : (ba > 0 ? 'var(--surface-2)' : 'transparent'),
color: isActive ? '#fff' : (ba > 0 ? 'var(--text)' : 'var(--text-muted)'),
cursor: ba > 0 ? 'pointer' : 'default',
fontWeight: isActive ? 600 : 400,
fontSize: 'var(--fs-sm)',
opacity: ba > 0 ? 1 : 0.5,
}}
disabled={ba === 0}
>
<span>{label}</span>
{ba > 0 && <span style={{ fontSize: 'var(--fs-xs)' }}>{Math.round(ba)} €</span>}
</button>
);
})}
</div>
</div>
{/* CERFA 2778-SD simulé */}
{moisData && (
<div style={{ flex: 1, minWidth: 400 }}>
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }}>
<h4 style={{ margin: 0, fontWeight: 700 }}>
Formulaire 2778-SD — {MOIS_LABELS[selectedMois]} {annee}
</h4>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Simulation indicative</span>
</div>
{/* Détail des plateformes incluses */}
{moisData.detail.length > 1 && (
<div style={{ marginBottom: 14, padding: '10px 12px', background: 'var(--surface-2)', borderRadius: 8 }}>
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginBottom: 6, fontWeight: 500, textTransform: 'uppercase', letterSpacing: '.05em' }}>
Plateformes incluses
</div>
{moisData.detail.map(d => (
<div key={d.nom} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
<span>{d.nom}</span>
<span style={{ fontWeight: 500 }}>{r(d.montant).toLocaleString('fr-FR', { minimumFractionDigits: 2 })} €</span>
</div>
))}
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--fs-sm)', fontWeight: 700, borderTop: '1px solid var(--border)', marginTop: 6, paddingTop: 6 }}>
<span>Total</span>
<span>{r(moisData.ba).toLocaleString('fr-FR', { minimumFractionDigits: 2 })} €</span>
</div>
</div>
)}
<CerfaBlock cases={moisData.cases} ba_exact={moisData.ba} mois={selectedMois} annee={annee} />
</div>
</div>
)}
{selectedMois === null && (
<div className="card" style={{ flex: 1, color: 'var(--text-muted)', textAlign: 'center', padding: 40 }}>
Sélectionnez un mois pour simuler le formulaire.
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
/* ── Report annuel 2042 ── */
function Report2042Block({ annee, totauxMois, rates, plateformes, excluded }) {
const totalBA = r(totauxMois.reduce((a, b) => a + b, 0));
if (totalBA === 0) return null;
const totalIA = computeCases(totalBA, rates).IA;
// Détail par plateforme incluse (annuel)
const platDetail = plateformes
.filter(p => !excluded.has(p.id))
.map(p => {
const ba = r(Object.values(p.mois).reduce((a, b) => a + b, 0));
return { nom: p.nom, ba, ia: computeCases(ba, rates).IA };
})
.filter(p => p.ba > 0);
const Case2042 = ({ code, label, note, value, breakdown }) => (
<div style={{ borderBottom: '1px solid var(--border)' }}>
<div style={{
display: 'grid', gridTemplateColumns: '90px 1fr auto',
gap: '0 12px', alignItems: 'center', padding: '10px 14px',
}}>
<div style={{
fontFamily: 'monospace', fontWeight: 800, fontSize: 'var(--fs-base)',
color: 'var(--primary)', background: 'rgba(99,102,241,0.07)',
borderRadius: 6, padding: '2px 6px', textAlign: 'center', letterSpacing: '.04em',
}}>{code}</div>
<div>
<div style={{ fontSize: 'var(--fs-sm)', fontWeight: 600 }}>{label}</div>
{note && <div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 2 }}>{note}</div>}
</div>
<div style={{ fontWeight: 800, fontSize: 'var(--fs-lg)', whiteSpace: 'nowrap', color: 'var(--text)' }}>
{Number(value).toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} €
</div>
</div>
{breakdown && breakdown.length > 1 && (
<div style={{ padding: '0 14px 10px 118px', display: 'flex', flexDirection: 'column', gap: 3 }}>
{breakdown.map(p => (
<div key={p.nom} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
<span>└ {p.nom}</span>
<span style={{ fontWeight: 500 }}>
{Number(p.val).toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} €
</span>
</div>
))}
</div>
)}
</div>
);
return (
<div className="card">
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 14 }}>
<h4 style={{ margin: 0, fontWeight: 700 }}>Report annuel — Déclaration 2042</h4>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Revenus {annee} à reporter sur la 2042 déposée en {Number(annee) + 1}</span>
</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden', marginBottom: 14 }}>
<Case2042
code="2TR"
label="Produits de placement à revenu fixe de source étrangère"
note="Intérêts bruts totaux encaissés sur l'année (somme des cases BA)"
value={Math.round(totalBA)}
breakdown={platDetail.map(p => ({ nom: p.nom, val: Math.round(p.ba) }))}
/>
<Case2042
code="2BH"
label="Produits soumis aux prélèvements sociaux (idem 2TR)"
note="Même montant que 2TR évite la double imposition aux prélèvements sociaux"
value={Math.round(totalBA)}
breakdown={platDetail.map(p => ({ nom: p.nom, val: Math.round(p.ba) }))}
/>
<Case2042
code="2CK"
label="Prélèvement forfaitaire non libératoire déjà versé (acompte)"
note="Somme des cases IA de vos 2778-SD s'impute sur l'IR définitif, l'excédent est restitué"
value={totalIA}
breakdown={platDetail.map(p => ({ nom: p.nom, val: p.ia }))}
/>
</div>
<div style={{
padding: '10px 14px', borderRadius: 8,
background: 'rgba(99,102,241,0.05)', border: '1px solid rgba(99,102,241,0.15)',
fontSize: 'var(--fs-xs)', lineHeight: 1.6, color: 'var(--text-muted)',
}}>
<strong style={{ color: 'var(--text)', display: 'block', marginBottom: 4 }}>Comment fonctionne le PFO ?</strong>
Le prélèvement forfaitaire obligatoire (PFO, case 2CK) versé via la 2778-SD est un <em>acompte</em> sur l'impôt sur le revenu, non libératoire.
L'imposition définitive est calculée lors de votre 2042 : par défaut au taux de <strong>{(rates.pfo * 100).toFixed(1)} %</strong> (Flat Tax),
ou sur option expresse au barème progressif. L'acompte déjà versé (2CK) s'impute sur l'IR définitif tout excédent vous est restitué.
Le montant 2BH est déclaré en sus de 2TR uniquement pour neutraliser les prélèvements sociaux déjà prélevés via la 2778-SD.
</div>
</div>
);
}
/* ── Bloc cases CERFA ── */
function CerfaBlock({ cases, ba_exact, mois, annee }) {
const { BA, IA, PQ, PV, PF1, PG1, PU, PK, QR } = cases;
const Row = ({ label, code, value, highlight, note, caseSup, base, taux, noUnit }) => (
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 90px 90px 90px 90px 90px',
gap: '0 8px',
alignItems: 'baseline',
padding: '8px 12px',
borderBottom: '1px solid var(--border)',
background: highlight ? 'rgba(239,68,68,0.05)' : 'transparent',
}}>
<div style={{ fontSize: 'var(--fs-sm)', whiteSpace: 'pre-line' }}>
{label}
{note && <span style={{ display: 'block', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 1 }}>{note}</span>}
</div>
<div style={{ fontSize: 'var(--fs-sm)', textAlign: 'center', color: 'var(--text-muted)' }}>{base != null ? `${base}` : ''}</div>
<div style={{ fontSize: 'var(--fs-xs)', textAlign: 'center', color: 'var(--text-muted)' }}>{taux ?? ''}</div>
<div style={{ fontFamily: 'monospace', fontWeight: 700, fontSize: 'var(--fs-sm)', color: 'var(--primary)', textAlign: 'center' }}>{code}</div>
<div style={{
fontWeight: highlight ? 700 : 600,
textAlign: 'center',
color: highlight ? 'var(--danger)' : 'var(--text)',
fontSize: highlight ? 'var(--fs-base)' : 'var(--fs-sm)',
}}>{value != null ? (noUnit ? value : `${value}`) : ''}</div>
<div style={{ fontFamily: 'monospace', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', alignSelf: 'center', textAlign: 'center' }}>{caseSup ?? ''}</div>
</div>
);
const SectionHeader = ({ title }) => (
<div style={{
padding: '6px 12px',
background: 'var(--surface-2)',
fontSize: 'var(--fs-xs)', fontWeight: 600,
color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.06em',
}}>{title}</div>
);
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
{/* En-tête */}
<div style={{
display: 'grid', gridTemplateColumns: '1fr 90px 90px 90px 90px 90px', gap: '0 8px',
padding: '6px 12px', background: 'var(--surface-2)',
fontSize: 'var(--fs-xs)', fontWeight: 600, color: 'var(--text-muted)',
borderBottom: '1px solid var(--border)',
}}>
<span>Libellé</span><span style={{ textAlign: 'center' }}>Base imposable (BA)</span><span style={{ textAlign: 'center' }}>Taux</span><span style={{ textAlign: 'center' }}>Case</span><span style={{ textAlign: 'center' }}>Montant</span><span></span>
</div>
<SectionHeader title="Page de garde (page 1)" />
<Row label="Mois concerné par la déclaration" note="Mois au cours duquel les revenus ont été encaissés" code="" value={mois != null ? `${MOIS_LABELS[mois]} ${annee ?? new Date().getFullYear()}` : '—'} noUnit />
<Row label="Paiement" note="Somme à payer — reporter le montant déterminé en dernière page, case QR" code="QR" value={QR} highlight />
<SectionHeader title="Prélèvement forfaitaire obligatoire non libératoire (page 2)" />
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)' }}>
<span style={{ fontSize: 'var(--fs-sm)', fontWeight: 600 }}>Produits de placement à revenu fixe de source étrangère soumis au prélèvement forfaitaire obligatoire non libératoire</span>
</div>
<Row
label={`Intérêts et produits des obligations, créances, dépôts, cautionnements, comptes courants, fonds communs de créances, bons de caisse.\nPrélèvement forfaitaire non libératoire (BA × ${(cases.pfo*100||12.8).toFixed(1)}%)` }
code="IA"
value={IA}
base={BA}
taux={`${(cases.pfo*100||12.8).toFixed(1)} %`}
note={ba_exact !== BA ? `Montant exact: ${r(ba_exact).toLocaleString('fr-FR', { minimumFractionDigits: 2 })} € → arrondi à ${BA} €` : null}
/>
<Row label="Total prélèvement forfaitaire non libératoire (IA + …)" code="" value={IA} caseSup="A422" />
<SectionHeader title="Prélèvements sociaux (page 3)" />
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)' }}>
<span style={{ fontSize: 'var(--fs-sm)', fontWeight: 600 }}>Produits de placements à revenu fixe et produits afférents aux versements déductibles faisant l'objet d'un retrait en capital des PER de source étrangère</span>
</div>
<Row label={`CSG (BA × ${(cases.csgRate*100||10.6).toFixed(1)}%)`} code="PQ" value={PQ} base={BA} taux={`${(cases.csgRate*100||10.6).toFixed(1)} %`} />
<Row label={`CRDS (BA × ${(cases.crdsRate*100||0.5).toFixed(1)}%)`} code="PV" value={PV} base={BA} taux={`${(cases.crdsRate*100||0.5).toFixed(1)} %`} />
<Row label="Total prélèvements sociaux hors solidarité (PQ + PV) → PF1" code="PF1" value={PF1} />
<Row label={`Prélèvement de solidarité (BA × ${(cases.solidRate*100||7.5).toFixed(1)}%)`} code="PG1" value={PG1} base={BA} taux={`${(cases.solidRate*100||7.5).toFixed(1)} %`} />
<SectionHeader title="Totaux à reporter (page 4)" />
<Row label="Total prélèvements sociaux hors solidarité (PF1 + …)" code="PU" value={PU} caseSup="0701" />
<Row label="Total prélèvement de solidarité (PG1 + …)" code="PK" value={PK} caseSup="A392" />
<div style={{
display: 'grid', gridTemplateColumns: '1fr 90px 90px 90px 90px 90px', gap: '0 8px',
padding: '12px 12px',
background: 'linear-gradient(135deg, rgba(239,68,68,0.08), rgba(239,68,68,0.04))',
borderTop: '2px solid var(--danger)',
}}>
<div style={{ fontWeight: 700, fontSize: 'var(--fs-sm)' }}>
MONTANT TOTAL À PAYER (IA + PU + PK)
<div style={{ fontSize: 'var(--fs-xs)', fontWeight: 400, color: 'var(--text-muted)', marginTop: 2 }}>
À reporter en première page du formulaire
</div>
</div>
<div></div><div></div>
<div style={{ fontFamily: 'monospace', fontWeight: 700, color: 'var(--danger)', fontSize: 'var(--fs-base)', alignSelf: 'center', textAlign: 'center' }}>QR</div>
<div style={{ fontWeight: 800, fontSize: '1.4rem', textAlign: 'center', color: 'var(--danger)', alignSelf: 'center' }}>{QR}</div>
<div></div>
</div>
</div>
);
}
+231
View File
@@ -0,0 +1,231 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { fmtEUR } from '../utils/format.js';
const ICONS_BASE = '/api/icons-files/';
/* Colonnes du tableau — dans l'ordre de la 2561 */
const COLS = [
{ key: 'case_2TT', code: '2TT', label: 'Produits prêts participatifs', color: '#7c3aed' },
{ key: 'case_2TR', code: '2TR', label: 'Produits placement revenu fixe', color: '#7c3aed' },
{ key: 'case_2TY', code: '2TY / AS',label: 'Pertes en capital', color: '#dc2626', danger: true },
{ key: 'case_2BH', code: '2BH', label: 'Base CSG/CRDS (PS prélevés)', color: '#1d4ed8' },
{ key: 'case_2CK', code: '2CK', label: "Crédit d'impôt prélèvement", color: '#059669' },
];
export default function CerfaRecapTable({ annee, activeView }) {
const [lignes, setLignes] = useState(null);
const [loading, setLoading] = useState(true);
const [libIcons, setLibIcons] = useState({});
const [showFr, setShowFr] = useState(true);
const [showWw, setShowWw] = useState(true);
/* Icônes bibliothèque */
useEffect(() => {
api.get('/icons').then(rows => {
const m = {};
rows.forEach(r => { m[r.name] = r.filename; });
setLibIcons(m);
}).catch(() => {});
}, []);
useEffect(() => {
setLoading(true);
setLignes(null);
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
api.get('/taxreport/cerfa2561', { annee, ...scopeParams })
.then(d => setLignes(d.lignes))
.finally(() => setLoading(false));
}, [annee, activeView]); // eslint-disable-line
/* Filtrage FR / WW */
const filtered = useMemo(() => {
if (!lignes) return [];
return lignes.filter(l => {
const isFr = l.domiciliation === 'FR';
return isFr ? showFr : showWw;
});
}, [lignes, showFr, showWw]);
const totals = useMemo(() => (
Object.fromEntries(
COLS.map(c => [c.key, filtered.reduce((s, l) => s + (l[c.key] || 0), 0)])
)
), [filtered]);
/* Composant icône bibliothèque */
const AppIcon = ({ name, size = 66, active }) => {
const filename = libIcons[name];
if (filename) return (
<img
src={ICONS_BASE + filename}
className="app-lib-icon app-lib-icon-no-invert"
width={size} height={size}
aria-hidden="true"
style={{ opacity: active ? 1 : 0.25, display: 'block', transition: 'opacity .15s' }}
/>
);
return (
<span style={{
width: size, height: size, display: 'block', borderRadius: 8,
background: 'var(--text-muted)', opacity: active ? 0.55 : 0.15,
transition: 'opacity .15s',
}} />
);
};
/* Détection présence de chaque type dans les données */
const hasFr = lignes?.some(l => l.domiciliation === 'FR') ?? false;
const hasWw = lignes?.some(l => l.domiciliation !== 'FR') ?? false;
/* Détecter si plusieurs détenteurs distincts */
const multiDetenteur =
new Set((lignes ?? []).map(l => l.investisseur_id).filter(v => v != null)).size > 1;
if (loading) {
return (
<div className="solde-chart-wrap" style={{ padding: '24px', marginBottom: 0 }}>
<span className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>Chargement</span>
</div>
);
}
if (!lignes || lignes.length === 0) {
return (
<div className="solde-chart-wrap" style={{ padding: '24px', marginBottom: 0 }}>
<span className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>Aucune donnée pour {annee}.</span>
</div>
);
}
return (
<div className="solde-chart-wrap" style={{ padding: '20px 24px 16px', marginBottom: 0 }}>
{/* Header */}
<div className="solde-chart-header" style={{ marginBottom: 12 }}>
<div className="solde-chart-info">
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', marginBottom: 2 }}>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
background: 'rgba(124,58,237,0.12)', borderRadius: 5, padding: '3px 8px',
}}>
<span style={{ width: 7, height: 7, borderRadius: 2, background: '#7c3aed', flexShrink: 0 }} />
<span style={{ fontSize: 13, color: '#7c3aed', fontWeight: 600 }}>Cases 2561</span>
</span>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>· {annee}</span>
</div>
<div className="solde-chart-value" style={{ fontSize: '1.4rem' }}>
{fmtEUR(totals.case_2TT + totals.case_2TR)}
</div>
</div>
{/* Boutons filtre FR / WW */}
<div className="solde-chart-controls">
{hasFr && (
<button
title={showFr ? 'Plateformes françaises incluses' : 'Afficher les plateformes françaises'}
onClick={() => setShowFr(v => !v)}
style={{
background: showFr ? 'rgba(59,130,246,0.12)' : 'none',
border: '1px solid ' + (showFr ? 'rgba(59,130,246,0.5)' : 'transparent'),
borderRadius: 10, padding: '5px 7px', cursor: 'pointer',
display: 'flex', alignItems: 'center',
transition: 'background .15s, border-color .15s',
}}
>
<div style={{ background: '#fff', borderRadius: 7, lineHeight: 0 }}>
<AppIcon name="plateforme-fr" size={44} active={showFr} />
</div>
</button>
)}
{hasWw && (
<button
title={showWw ? 'Plateformes étrangères incluses' : 'Afficher les plateformes étrangères'}
onClick={() => setShowWw(v => !v)}
style={{
background: showWw ? 'rgba(16,185,129,0.12)' : 'none',
border: '1px solid ' + (showWw ? 'rgba(16,185,129,0.5)' : 'transparent'),
borderRadius: 10, padding: '5px 7px', cursor: 'pointer',
display: 'flex', alignItems: 'center',
transition: 'background .15s, border-color .15s',
}}
>
<div style={{ background: '#fff', borderRadius: 7, lineHeight: 0 }}>
<AppIcon name="plateforme-ww" size={44} active={showWw} />
</div>
</button>
)}
</div>
</div>
{filtered.length === 0 ? (
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>
Aucune plateforme à afficher activez au moins un filtre.
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table className="tip-table">
<thead>
<tr>
<th className="tip-th-name tip-th-name-amber">Plateforme</th>
{COLS.map(c => (
<th key={c.key} className="tip-th-month" style={{ minWidth: 110 }}>
<span style={{ display: 'block', fontWeight: 800, letterSpacing: '.03em' }}>{c.code}</span>
<span style={{ display: 'block', fontSize: 10, fontWeight: 400, opacity: .85, marginTop: 1 }}>{c.label}</span>
</th>
))}
</tr>
</thead>
<tbody>
{filtered.map(l => (
<tr key={`${l.plateforme_id}_${l.investisseur_id}`} className="tip-row-plat">
<td className="tip-td-name">
{l.plateforme_nom}
{multiDetenteur && l.investisseur_prenom && (
<span style={{ marginLeft: 6, fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>
{l.investisseur_prenom}
</span>
)}
</td>
{COLS.map(c => {
const v = l[c.key] || 0;
return (
<td
key={c.key}
className="tip-td-num"
style={c.danger && v > 0 ? { color: '#dc2626', fontWeight: 600 } : undefined}
>
{v > 0 ? fmtEUR(v) : <span className="tip-dash">---</span>}
</td>
);
})}
</tr>
))}
</tbody>
<tfoot>
<tr className="tip-footer-total">
<td className="tip-td-name">Total {annee}</td>
{COLS.map(c => {
const v = totals[c.key] || 0;
return (
<td
key={c.key}
className="tip-td-total"
style={c.danger && v > 0 ? { color: '#dc2626' } : undefined}
>
{v > 0 ? fmtEUR(v) : <span className="tip-dash">---</span>}
</td>
);
})}
</tr>
</tfoot>
</table>
</div>
)}
<p style={{ margin: '10px 0 0', fontSize: 11, color: 'var(--text-muted)' }}>
Montants indicatifs. Référez-vous à votre IFU et à la notice 2041-GFI avant toute déclaration.
</p>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import Modal from './Modal.jsx';
/**
* Modale de confirmation générique.
* Props :
* open — booléen
* 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"
* onCancel — callback appelé au clic "Annuler" ou ✕
*/
export default function ConfirmModal({
open,
title = 'Confirmer la suppression',
message,
confirmLabel = 'Supprimer',
onConfirm,
onCancel,
}) {
return (
<Modal
open={open}
title={title}
onClose={onCancel}
width={440}
footer={
<>
<button className="ghost" type="button" onClick={onCancel}>Annuler</button>
<button className="danger" type="button" onClick={onConfirm}>{confirmLabel}</button>
</>
}
>
<p style={{ margin: '8px 0 4px', color: 'var(--text)', lineHeight: 1.5 }}>{message}</p>
</Modal>
);
}
+383
View File
@@ -0,0 +1,383 @@
import { useState, useRef, useEffect } from 'react';
// Composant drapeau via flag-icons CSS (cdnjs)
// Usage : <FlagIcon code="FR" />
const FlagIcon = ({ code, size = 20 }) => (
<span
className={`fi fi-${(code || 'fr').toLowerCase()}`}
style={{ width: size, height: Math.round(size * 0.75), display: 'inline-block', flexShrink: 0, borderRadius: 2 }}
/>
);
// Liste complète ISO 3166-1 alpha-2 — noms en français
const COUNTRIES = [
{ code: 'AF', name: 'Afghanistan' },
{ code: 'ZA', name: 'Afrique du Sud' },
{ code: 'AL', name: 'Albanie' },
{ code: 'DZ', name: 'Algérie' },
{ code: 'DE', name: 'Allemagne' },
{ code: 'AD', name: 'Andorre' },
{ code: 'AO', name: 'Angola' },
{ code: 'AG', name: 'Antigua-et-Barbuda' },
{ code: 'SA', name: 'Arabie saoudite' },
{ code: 'AR', name: 'Argentine' },
{ code: 'AM', name: 'Arménie' },
{ code: 'AU', name: 'Australie' },
{ code: 'AT', name: 'Autriche' },
{ code: 'AZ', name: 'Azerbaïdjan' },
{ code: 'BS', name: 'Bahamas' },
{ code: 'BH', name: 'Bahreïn' },
{ code: 'BD', name: 'Bangladesh' },
{ code: 'BB', name: 'Barbade' },
{ code: 'BY', name: 'Biélorussie' },
{ code: 'BE', name: 'Belgique' },
{ code: 'BZ', name: 'Belize' },
{ code: 'BJ', name: 'Bénin' },
{ code: 'BT', name: 'Bhoutan' },
{ code: 'BO', name: 'Bolivie' },
{ code: 'BA', name: 'Bosnie-Herzégovine' },
{ code: 'BW', name: 'Botswana' },
{ code: 'BR', name: 'Brésil' },
{ code: 'BN', name: 'Brunéi' },
{ code: 'BG', name: 'Bulgarie' },
{ code: 'BF', name: 'Burkina Faso' },
{ code: 'BI', name: 'Burundi' },
{ code: 'CV', name: 'Cap-Vert' },
{ code: 'KH', name: 'Cambodge' },
{ code: 'CM', name: 'Cameroun' },
{ code: 'CA', name: 'Canada' },
{ code: 'CF', name: 'République centrafricaine' },
{ code: 'CL', name: 'Chili' },
{ code: 'CN', name: 'Chine' },
{ code: 'CY', name: 'Chypre' },
{ code: 'CO', name: 'Colombie' },
{ code: 'KM', name: 'Comores' },
{ code: 'CG', name: 'Congo' },
{ code: 'CD', name: 'Congo (RDC)' },
{ code: 'KP', name: 'Corée du Nord' },
{ code: 'KR', name: 'Corée du Sud' },
{ code: 'CR', name: 'Costa Rica' },
{ code: 'HR', name: 'Croatie' },
{ code: 'CU', name: 'Cuba' },
{ code: 'DK', name: 'Danemark' },
{ code: 'DJ', name: 'Djibouti' },
{ code: 'DO', name: 'République dominicaine' },
{ code: 'DM', name: 'Dominique' },
{ code: 'EG', name: 'Égypte' },
{ code: 'SV', name: 'Salvador' },
{ code: 'AE', name: 'Émirats arabes unis' },
{ code: 'EC', name: 'Équateur' },
{ code: 'ER', name: 'Érythrée' },
{ code: 'ES', name: 'Espagne' },
{ code: 'EE', name: 'Estonie' },
{ code: 'SZ', name: 'Eswatini' },
{ code: 'ET', name: 'Éthiopie' },
{ code: 'FJ', name: 'Fidji' },
{ code: 'FI', name: 'Finlande' },
{ code: 'FR', name: 'France' },
{ code: 'GA', name: 'Gabon' },
{ code: 'GM', name: 'Gambie' },
{ code: 'GE', name: 'Géorgie' },
{ code: 'GH', name: 'Ghana' },
{ code: 'GR', name: 'Grèce' },
{ code: 'GD', name: 'Grenade' },
{ code: 'GT', name: 'Guatemala' },
{ code: 'GN', name: 'Guinée' },
{ code: 'GW', name: 'Guinée-Bissau' },
{ code: 'GQ', name: 'Guinée équatoriale' },
{ code: 'GY', name: 'Guyana' },
{ code: 'HT', name: 'Haïti' },
{ code: 'HN', name: 'Honduras' },
{ code: 'HU', name: 'Hongrie' },
{ code: 'IN', name: 'Inde' },
{ code: 'ID', name: 'Indonésie' },
{ code: 'IQ', name: 'Irak' },
{ code: 'IR', name: 'Iran' },
{ code: 'IE', name: 'Irlande' },
{ code: 'IS', name: 'Islande' },
{ code: 'IL', name: 'Israël' },
{ code: 'IT', name: 'Italie' },
{ code: 'JM', name: 'Jamaïque' },
{ code: 'JP', name: 'Japon' },
{ code: 'JO', name: 'Jordanie' },
{ code: 'KZ', name: 'Kazakhstan' },
{ code: 'KE', name: 'Kenya' },
{ code: 'KG', name: 'Kirghizistan' },
{ code: 'KI', name: 'Kiribati' },
{ code: 'KW', name: 'Koweït' },
{ code: 'LA', name: 'Laos' },
{ code: 'LS', name: 'Lesotho' },
{ code: 'LV', name: 'Lettonie' },
{ code: 'LB', name: 'Liban' },
{ code: 'LR', name: 'Liberia' },
{ code: 'LY', name: 'Libye' },
{ code: 'LI', name: 'Liechtenstein' },
{ code: 'LT', name: 'Lituanie' },
{ code: 'LU', name: 'Luxembourg' },
{ code: 'MK', name: 'Macédoine du Nord' },
{ code: 'MG', name: 'Madagascar' },
{ code: 'MY', name: 'Malaisie' },
{ code: 'MW', name: 'Malawi' },
{ code: 'MV', name: 'Maldives' },
{ code: 'ML', name: 'Mali' },
{ code: 'MT', name: 'Malte' },
{ code: 'MA', name: 'Maroc' },
{ code: 'MH', name: 'Îles Marshall' },
{ code: 'MU', name: 'Maurice' },
{ code: 'MR', name: 'Mauritanie' },
{ code: 'MX', name: 'Mexique' },
{ code: 'FM', name: 'Micronésie' },
{ code: 'MD', name: 'Moldavie' },
{ code: 'MC', name: 'Monaco' },
{ code: 'MN', name: 'Mongolie' },
{ code: 'ME', name: 'Monténégro' },
{ code: 'MZ', name: 'Mozambique' },
{ code: 'MM', name: 'Myanmar' },
{ code: 'NA', name: 'Namibie' },
{ code: 'NR', name: 'Nauru' },
{ code: 'NP', name: 'Népal' },
{ code: 'NI', name: 'Nicaragua' },
{ code: 'NE', name: 'Niger' },
{ code: 'NG', name: 'Nigeria' },
{ code: 'NO', name: 'Norvège' },
{ code: 'NZ', name: 'Nouvelle-Zélande' },
{ code: 'OM', name: 'Oman' },
{ code: 'UG', name: 'Ouganda' },
{ code: 'UZ', name: 'Ouzbékistan' },
{ code: 'PK', name: 'Pakistan' },
{ code: 'PW', name: 'Palaos' },
{ code: 'PA', name: 'Panama' },
{ code: 'PG', name: 'Papouasie-Nouvelle-Guinée' },
{ code: 'PY', name: 'Paraguay' },
{ code: 'NL', name: 'Pays-Bas' },
{ code: 'PE', name: 'Pérou' },
{ code: 'PH', name: 'Philippines' },
{ code: 'PL', name: 'Pologne' },
{ code: 'PT', name: 'Portugal' },
{ code: 'QA', name: 'Qatar' },
{ code: 'RO', name: 'Roumanie' },
{ code: 'GB', name: 'Royaume-Uni' },
{ code: 'RU', name: 'Russie' },
{ code: 'RW', name: 'Rwanda' },
{ code: 'KN', name: 'Saint-Kitts-et-Nevis' },
{ code: 'SM', name: 'Saint-Marin' },
{ code: 'VC', name: 'Saint-Vincent-et-les-Grenadines' },
{ code: 'LC', name: 'Sainte-Lucie' },
{ code: 'SB', name: 'Îles Salomon' },
{ code: 'WS', name: 'Samoa' },
{ code: 'ST', name: 'Sao Tomé-et-Principe' },
{ code: 'SN', name: 'Sénégal' },
{ code: 'RS', name: 'Serbie' },
{ code: 'SC', name: 'Seychelles' },
{ code: 'SL', name: 'Sierra Leone' },
{ code: 'SG', name: 'Singapour' },
{ code: 'SK', name: 'Slovaquie' },
{ code: 'SI', name: 'Slovénie' },
{ code: 'SO', name: 'Somalie' },
{ code: 'SD', name: 'Soudan' },
{ code: 'SS', name: 'Soudan du Sud' },
{ code: 'LK', name: 'Sri Lanka' },
{ code: 'SE', name: 'Suède' },
{ code: 'CH', name: 'Suisse' },
{ code: 'SR', name: 'Suriname' },
{ code: 'SY', name: 'Syrie' },
{ code: 'TJ', name: 'Tadjikistan' },
{ code: 'TZ', name: 'Tanzanie' },
{ code: 'TD', name: 'Tchad' },
{ code: 'CZ', name: 'Tchéquie' },
{ code: 'TH', name: 'Thaïlande' },
{ code: 'TL', name: 'Timor oriental' },
{ code: 'TG', name: 'Togo' },
{ code: 'TO', name: 'Tonga' },
{ code: 'TT', name: 'Trinité-et-Tobago' },
{ code: 'TN', name: 'Tunisie' },
{ code: 'TM', name: 'Turkménistan' },
{ code: 'TR', name: 'Turquie' },
{ code: 'TV', name: 'Tuvalu' },
{ code: 'UA', name: 'Ukraine' },
{ code: 'UY', name: 'Uruguay' },
{ code: 'VU', name: 'Vanuatu' },
{ code: 'VE', name: 'Venezuela' },
{ code: 'VN', name: 'Viêt Nam' },
{ code: 'YE', name: 'Yémen' },
{ code: 'ZM', name: 'Zambie' },
{ code: 'ZW', name: 'Zimbabwe' },
{ code: 'US', name: 'États-Unis' },
].sort((a, b) => a.name.localeCompare(b.name, 'fr'));
export { COUNTRIES, FlagIcon };
/**
* CountrySelect — combobox pays avec drapeaux emoji et recherche par frappe
*
* Props :
* value : code ISO 2 lettres (ex. 'FR')
* onChange : (code) => void
* required : bool (optionnel)
*/
export default function CountrySelect({ value, onChange, required, showCode }) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const containerRef = useRef(null);
const inputRef = useRef(null);
const listRef = useRef(null);
const selected = COUNTRIES.find(c => c.code === value);
const filtered = search
? COUNTRIES.filter(c =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
c.code.toLowerCase().includes(search.toLowerCase())
)
: COUNTRIES;
// Ferme le dropdown si clic en dehors
useEffect(() => {
const handler = (e) => {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setOpen(false);
setSearch('');
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const select = (code) => {
onChange(code);
setOpen(false);
setSearch('');
};
const handleOpen = () => {
setOpen(true);
setSearch('');
setTimeout(() => inputRef.current?.focus(), 0);
};
// Navigation clavier dans la liste
const handleKeyDown = (e) => {
if (e.key === 'Escape') { setOpen(false); setSearch(''); }
};
return (
<div ref={containerRef} style={{ position: 'relative' }}>
{/* Trigger */}
<button
type="button"
onClick={open ? () => { setOpen(false); setSearch(''); } : handleOpen}
style={{
width: '100%',
textAlign: 'left',
padding: '0 10px',
height: 36,
background: 'var(--surface-2, var(--surface))',
border: open ? '1px solid var(--primary)' : '1px solid var(--border)',
borderRadius: 6,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 14,
color: 'var(--text)',
outline: open ? '2px solid var(--primary)' : 'none',
outlineOffset: 1,
}}
>
{selected ? (
<>
<FlagIcon code={selected.code} />
<span>{selected.name}</span>
{showCode && <span style={{ color: 'var(--text-muted)', fontSize: 12 }}>({selected.code})</span>}
</>
) : (
<span style={{ color: 'var(--text-muted)' }}>Sélectionner un pays</span>
)}
<svg style={{ marginLeft: 'auto', flexShrink: 0, opacity: 0.5, transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="6 9 12 15 18 9"/></svg>
</button>
{/* Dropdown */}
{open && (
<div
style={{
position: 'absolute',
bottom: 'calc(100% + 4px)',
left: 0,
right: 0,
zIndex: 500,
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
boxShadow: '0 8px 24px rgba(0,0,0,0.15)',
overflow: 'hidden',
}}
>
{/* Champ de recherche */}
<div style={{ padding: '8px 8px 4px' }}>
<input
ref={inputRef}
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Rechercher un pays…"
style={{
width: '100%',
boxSizing: 'border-box',
padding: '6px 10px',
border: '1px solid var(--border)',
borderRadius: 6,
background: 'var(--surface-2, var(--surface))',
color: 'var(--text)',
fontSize: 13,
outline: 'none',
}}
/>
</div>
{/* Liste des pays */}
<div
ref={listRef}
style={{
maxHeight: 128,
overflowY: 'auto',
padding: '4px 4px 8px',
}}
>
{filtered.length === 0 && (
<div style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 13 }}>
Aucun résultat
</div>
)}
{filtered.map(c => (
<button
key={c.code}
type="button"
onClick={() => select(c.code)}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
width: '100%',
padding: '6px 10px',
border: 'none',
borderRadius: 5,
background: c.code === value ? 'var(--primary-light, rgba(99,102,241,0.1))' : 'transparent',
color: c.code === value ? 'var(--primary)' : 'var(--text)',
cursor: 'pointer',
fontSize: 13,
textAlign: 'left',
fontWeight: c.code === value ? 600 : 400,
}}
>
<FlagIcon code={c.code} />
<span>{c.name}</span>
<span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--text-muted)', opacity: 0.7 }}>{c.code}</span>
</button>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,321 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { fmtEUR } from '../utils/format.js';
const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
const ICONS_BASE = '/api/icons-files/';
const COLOR_DEPOT = '#22c55e';
const COLOR_RETRAIT = '#ef4444';
function hexToRgba(hex, a) {
if (!hex || hex.length < 7) return `rgba(0,0,0,${a})`;
const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16);
return `rgba(${r},${g},${b},${a})`;
}
function ChevronDown({ size = 10 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9l6 6 6-6"/>
</svg>
);
}
export default function DepotsMensuelTable({ allRows, plats, expandButton }) {
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
const [annee, setAnnee] = useState(currentYear);
const [inclureDepots, setInclureDepots] = useState(true);
const [inclureRetraits, setInclureRetraits] = useState(false);
const [libIcons, setLibIcons] = useState({});
/* ── Toggle consolidation détenteurs ── */
const [groupByNom, setGroupByNom] = useState(() => {
try { return localStorage.getItem('cl_tip_group_by_nom') === 'true'; } catch { return false; }
});
const toggleGroupByNom = () => {
setGroupByNom(v => {
const next = !v;
try { localStorage.setItem('cl_tip_group_by_nom', String(next)); } catch {}
return next;
});
};
/* Icones bibliotheque */
useEffect(() => {
api.get('/icons').then(rows => {
const m = {};
rows.forEach(r => { m[r.name] = r.filename; });
setLibIcons(m);
}).catch(() => {});
}, []);
const AppIcon = ({ name, size = 28, active = false }) => {
const filename = libIcons[name];
if (filename) return (
<img src={ICONS_BASE + filename} className="app-lib-icon" width={size} height={size}
aria-hidden="true"
style={{ opacity: active ? 1 : 0.35, display: 'block', transition: 'opacity .15s' }} />
);
return <span style={{ width: size, height: size, display: 'block', borderRadius: 4,
background: 'var(--text-muted)', opacity: active ? 0.55 : 0.2, transition: 'opacity .15s' }} />;
};
/* Annees disponibles */
const availableYears = useMemo(() => {
const set = new Set(allRows.map(r => r.date_operation?.slice(0, 4)).filter(Boolean));
return [...set].map(Number).sort((a, b) => a - b);
}, [allRows]);
/* Fenetre selecteur */
const [windowStart, setWindowStart] = useState(() => {
const idx = availableYears.indexOf(currentYear);
const safe = idx >= 0 ? idx : availableYears.length - 1;
return Math.max(0, Math.min(Math.max(0, availableYears.length - 3), safe - 1));
});
const visibleYears = availableYears.length ? availableYears.slice(windowStart, windowStart + 3) : [annee];
const canPrev = windowStart > 0;
const canNext = windowStart + 3 < availableYears.length;
/* Grille par plateforme x mois */
const { grid, stats, multiDetenteur } = useMemo(() => {
const anneeStr = String(annee);
const rows = allRows.filter(r => r.date_operation?.slice(0, 4) === anneeStr);
const byPlat = {};
for (const r of rows) {
const pid = r.plateforme_id;
if (!byPlat[pid]) {
byPlat[pid] = {
id: pid,
nom: r.plateforme_nom || '—',
investisseur_id: r.investisseur_id ?? null,
detenteur_nom: r.plateforme_detenteur_nom || null,
depots: Array(12).fill(0),
retraits: Array(12).fill(0),
};
}
const mi = parseInt(r.date_operation.slice(5, 7), 10) - 1;
if (r.type === 'depot') byPlat[pid].depots[mi] += r.montant || 0;
if (r.type === 'retrait') byPlat[pid].retraits[mi] += r.montant || 0;
}
const allPlats = Object.values(byPlat);
const multi = new Set(allPlats.map(p => p.investisseur_id).filter(v => v != null)).size > 1;
// Consolidation par nom si demandée
let consolidated;
if (groupByNom && multi) {
const byNom = {};
for (const p of allPlats) {
if (!byNom[p.nom]) {
byNom[p.nom] = { id: p.nom, nom: p.nom, investisseur_id: null, detenteur_nom: null,
depots: [...p.depots], retraits: [...p.retraits] };
} else {
for (let i = 0; i < 12; i++) {
byNom[p.nom].depots[i] += p.depots[i];
byNom[p.nom].retraits[i] += p.retraits[i];
}
}
}
consolidated = Object.values(byNom);
} else {
consolidated = allPlats;
}
const cellValue = (p, mi) => {
const d = inclureDepots ? p.depots[mi] : 0;
const rv = inclureRetraits ? p.retraits[mi] : 0;
if (inclureDepots && inclureRetraits) return d - rv;
return d + rv;
};
const grid = consolidated
.map(p => ({ ...p, months: Array.from({ length: 12 }, (_, i) => cellValue(p, i)) }))
.filter(p => p.months.some(v => v !== 0))
.sort((a, b) => b.depots.reduce((s, v) => s + v, 0) - a.depots.reduce((s, v) => s + v, 0));
const monthTotals = Array.from({ length: 12 }, (_, i) =>
grid.reduce((s, row) => s + row.months[i], 0));
const grandTotal = monthTotals.reduce((s, v) => s + v, 0);
const platTotals = grid.map(row => row.months.reduce((s, v) => s + v, 0));
const nonZero = monthTotals.filter(v => v !== 0);
const globalMoyenne = nonZero.length ? nonZero.reduce((s, v) => s + v, 0) / nonZero.length : 0;
return { grid, stats: { monthTotals, grandTotal, platTotals, globalMoyenne }, multiDetenteur: multi };
}, [allRows, annee, inclureDepots, inclureRetraits, groupByNom]);
return (
<div className="solde-chart-wrap" style={{ padding: '24px 24px 16px', marginBottom: 24 }}>
<div className="solde-chart-header">
<div className="solde-chart-info">
<div style={{ display:'flex', alignItems:'center', gap:5, flexWrap:'wrap', marginBottom:2 }}>
{inclureDepots && (
<span style={{ display:'inline-flex', alignItems:'center', gap:4,
background: hexToRgba(COLOR_DEPOT, 0.12), borderRadius:5, padding:'3px 8px' }}>
<span style={{ width:7, height:7, borderRadius:2, background: COLOR_DEPOT, flexShrink:0 }}/>
<span style={{ fontSize:13, color: COLOR_DEPOT, fontWeight:600 }}>Depots</span>
</span>
)}
{inclureRetraits && (
<span style={{ display:'inline-flex', alignItems:'center', gap:4,
background: hexToRgba(COLOR_RETRAIT, 0.12), borderRadius:5, padding:'3px 8px' }}>
<span style={{ width:7, height:7, borderRadius:2, background: COLOR_RETRAIT, flexShrink:0 }}/>
<span style={{ fontSize:13, color: COLOR_RETRAIT, fontWeight:600 }}>Retraits</span>
</span>
)}
{!inclureDepots && !inclureRetraits && (
<span style={{ fontSize:13, color:'var(--text-muted)' }}>---</span>
)}
<span style={{ fontSize:13, color:'var(--text-muted)' }}>. {annee}</span>
</div>
<div className="solde-chart-value">{fmtEUR(stats.grandTotal)}</div>
</div>
<div className="solde-chart-controls">
<button
title={inclureDepots ? 'Depots inclus' : 'Inclure les depots'}
onClick={() => setInclureDepots(v => !v)}
style={{ background: inclureDepots ? hexToRgba(COLOR_DEPOT, 0.13) : 'none',
border: '1px solid ' + (inclureDepots ? COLOR_DEPOT : 'transparent'),
borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center',
transition:'background .15s,border-color .15s', marginRight:2 }}>
<AppIcon name="depot" active={inclureDepots} />
</button>
<button
title={inclureRetraits ? 'Retraits inclus' : 'Inclure les retraits'}
onClick={() => setInclureRetraits(v => !v)}
style={{ background: inclureRetraits ? hexToRgba(COLOR_RETRAIT, 0.13) : 'none',
border: '1px solid ' + (inclureRetraits ? COLOR_RETRAIT : 'transparent'),
borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center',
transition:'background .15s,border-color .15s', marginRight:2 }}>
<AppIcon name="retrait" active={inclureRetraits} />
</button>
<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 }}>&#x2039;</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 }}>&#x203a;</button>
<button
className={'solde-range-btn' + (annee === currentYear ? ' active' : '')}
onClick={() => setAnnee(currentYear)}>
TOUT
</button>
{expandButton}
</div>
</div>
</div>
{grid.length === 0 ? (
<div style={{ marginTop: 20, color: 'var(--text-muted)', fontSize: 'var(--fs-sm)', padding: '24px 0', textAlign: 'center' }}>
{!inclureDepots && !inclureRetraits
? 'Selectionne au moins un type de mouvement.'
: 'Aucun mouvement 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">
<span style={{ display:'flex', alignItems:'center', gap:6 }}>
Plateforme
{multiDetenteur && (
<button
onClick={() => toggleGroupByNom()}
title={groupByNom ? 'Vue consolidée — cliquer pour détailler par détenteur' : 'Vue détaillée — cliquer pour consolider par plateforme'}
style={{
display:'inline-flex', alignItems:'center', gap:3,
background:'rgba(255,255,255,0.15)', border:'1px solid rgba(255,255,255,0.3)',
borderRadius:4, padding:'2px 5px', cursor:'pointer',
fontSize:10, fontWeight:600, color:'#fff', letterSpacing:'.03em',
lineHeight:1.4, whiteSpace:'nowrap', transition:'background .15s',
}}>
{groupByNom ? 'Consolidé' : 'Détaillé'}
<span style={{ display:'inline-flex', transition:'transform .2s', transform: groupByNom ? 'rotate(180deg)' : 'rotate(0deg)' }}>
<ChevronDown size={9} />
</span>
</button>
)}
</span>
</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">Total</th>
<th className="tip-th-avg">Moy. mensuelle</th>
</tr>
</thead>
<tbody>
{grid.map((plat, pi) => (
<tr key={plat.id} className="tip-row-plat">
<td className="tip-td-name">
{plat.nom}
{!groupByNom && 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' : '')}
style={v < 0 ? { color: COLOR_RETRAIT } : undefined}>
{v !== 0 ? fmtEUR(v) : <span className="tip-dash">---</span>}
</td>
))}
<td className="tip-td-total" style={stats.platTotals[pi] < 0 ? { color: COLOR_RETRAIT } : undefined}>
{stats.platTotals[pi] !== 0 ? fmtEUR(stats.platTotals[pi]) : <span className="tip-dash">---</span>}
</td>
<td className="tip-td-avg">
{stats.platTotals[pi] !== 0 ? fmtEUR(stats.platTotals[pi] / 12) : <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' : '')}
style={v < 0 ? { color: COLOR_RETRAIT } : undefined}>
{v !== 0 ? fmtEUR(v) : <span className="tip-dash">---</span>}
</td>
))}
<td className="tip-td-total" style={stats.grandTotal < 0 ? { color: COLOR_RETRAIT } : undefined}>
{fmtEUR(stats.grandTotal)}
</td>
<td className="tip-td-avg">
{stats.globalMoyenne !== 0 ? fmtEUR(stats.globalMoyenne) : <span className="tip-dash">---</span>}
</td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,292 @@
import { useMemo, useState, useRef, useCallback } from 'react';
/* ── Palette ────────────────────────────────────────────────── */
const PALETTE = [
'#e8547a', '#9333ea', '#0d9488', '#f59e0b',
'#3b82f6', '#10b981', '#f97316', '#06b6d4',
'#a855f7', '#84cc16', '#ec4899', '#14b8a6',
];
/* ── Algorithme treemap (binary split équilibré) ────────────── */
function buildTreemap(items, x, y, w, h) {
if (!items.length) return [];
if (items.length === 1) return [{ ...items[0], x, y, w, h }];
const total = items.reduce((s, i) => s + i.value, 0);
let best = 1, bestDiff = Infinity, acc = 0;
for (let i = 0; i < items.length - 1; i++) {
acc += items[i].value;
const diff = Math.abs(acc - (total - acc));
if (diff < bestDiff) { bestDiff = diff; best = i + 1; }
}
const g1 = items.slice(0, best);
const g2 = items.slice(best);
const r1 = g1.reduce((s, i) => s + i.value, 0) / total;
if (w >= h) {
const w1 = w * r1;
return [
...buildTreemap(g1, x, y, w1, h),
...buildTreemap(g2, x + w1, y, w - w1, h),
];
} else {
const h1 = h * r1;
return [
...buildTreemap(g1, x, y, w, h1),
...buildTreemap(g2, x, y + h1, w, h - h1),
];
}
}
/* ── Formatage : nombre entier arrondi, jamais de k€ ── */
function fmtAmount(v) {
return Math.round(v).toLocaleString('fr-FR') + ' €';
}
/* ── Word-wrap SVG : découpe un nom en lignes selon la largeur dispo ── */
function wrapText(text, maxWidth, fontSize) {
const charW = fontSize * 0.58; // largeur approx d'un caractère
const maxChars = Math.max(1, Math.floor(maxWidth / charW));
const words = text.split(' ');
const lines = [];
let current = '';
for (const word of words) {
const candidate = current ? current + ' ' + word : word;
if (candidate.length <= maxChars) {
current = candidate;
} else {
if (current) lines.push(current);
current = word.length > maxChars ? word.slice(0, maxChars - 1) + '…' : word;
}
}
if (current) lines.push(current);
return lines.slice(0, 3); // max 3 lignes
}
/* ── Composant ──────────────────────────────────────────────── */
export default function DistributionChart({ rows }) {
const [hoveredIdx, setHoveredIdx] = useState(null);
const [tooltip, setTooltip] = useState(null); // { x, y, cell }
const svgRef = useRef(null);
const wrapRef = useRef(null);
/* Solde net par plateforme */
const { data, allRetraits } = useMemo(() => {
if (!rows?.length) return { data: [], allRetraits: false };
const allRetraits = rows.every(r => r.type === 'retrait');
const byPlat = {};
for (const r of rows) {
const key = r.plateforme_nom || `#${r.plateforme_id}`;
byPlat[key] = (byPlat[key] || 0) + r.montant;
}
const data = Object.entries(byPlat)
.filter(([, v]) => v > 0)
.sort(([, a], [, b]) => b - a)
.map(([name, value], i) => ({ name, value, color: PALETTE[i % PALETTE.length] }));
return { data, allRetraits };
}, [rows]);
const total = data.reduce((s, i) => s + i.value, 0);
const W = 440, H = 290, GAP = 3;
const cells = useMemo(() => buildTreemap(data, 0, 0, W, H), [data]);
/* Conversion coordonnées SVG → pixels dans le wrapper */
const handleMouseMove = useCallback((e, cell, idx) => {
if (!svgRef.current || !wrapRef.current) return;
const svgRect = svgRef.current.getBoundingClientRect();
const wrapRect = wrapRef.current.getBoundingClientRect();
// Position relative au wrapper (pour le tooltip absolu)
const tx = e.clientX - wrapRect.left;
const ty = e.clientY - wrapRect.top;
setHoveredIdx(idx);
setTooltip({ x: tx, y: ty, cell });
}, []);
const handleMouseLeave = useCallback(() => {
setHoveredIdx(null);
setTooltip(null);
}, []);
if (!cells.length) return null;
/* Position tooltip : évite les débordements */
const TIP_W = 150, TIP_H = 66;
const tipStyle = tooltip ? (() => {
const ww = wrapRef.current?.offsetWidth || 400;
const wh = wrapRef.current?.offsetHeight || 360;
let tx = tooltip.x + 14;
let ty = tooltip.y - TIP_H / 2;
if (tx + TIP_W > ww - 4) tx = tooltip.x - TIP_W - 14;
if (ty < 4) ty = 4;
if (ty + TIP_H > wh - 4) ty = wh - TIP_H - 4;
return { left: tx, top: ty };
})() : null;
return (
<div className="dist-chart-wrap" ref={wrapRef}>
{/* ── En-tête ── */}
<div className="dist-chart-header">
<span className="dist-chart-title">Distribution</span>
<div className="dist-dropdown">
par Plateforme
<svg width="10" height="10" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9l6 6 6-6"/>
</svg>
</div>
</div>
{/* ── Treemap SVG ── */}
<div style={{ position: 'relative' }}>
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
style={{ width: '100%', height: 'auto', display: 'block' }}
onMouseLeave={handleMouseLeave}
>
<defs>
<linearGradient id="dt-shadow" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#000" stopOpacity="0" />
<stop offset="100%" stopColor="#000" stopOpacity="0.4" />
</linearGradient>
<filter id="dt-txt-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="1" stdDeviation="1.5" floodColor="#000" floodOpacity="0.55"/>
</filter>
{/* clipPath par cellule — défini dynamiquement dans chaque <g> */}
{cells.map((cell, i) => {
const PAD = 4;
const gx = cell.x + GAP / 2 + PAD;
const gy = cell.y + GAP / 2 + PAD;
const gw = Math.max(cell.w - GAP - PAD * 2, 0);
const gh = Math.max(cell.h - GAP - PAD * 2, 0);
return (
<clipPath key={i} id={`clip-${i}`}>
<rect x={gx} y={gy} width={gw} height={gh} />
</clipPath>
);
})}
</defs>
{cells.map((cell, i) => {
const gx = cell.x + GAP / 2;
const gy = cell.y + GAP / 2;
const gw = Math.max(cell.w - GAP, 0);
const gh = Math.max(cell.h - GAP, 0);
const cx = gx + gw / 2;
const cy = gy + gh / 2;
const pct = ((cell.value / total) * 100).toFixed(0);
const amt = (allRetraits ? ' ' : '') + fmtAmount(cell.value);
const isHovered = hoveredIdx === i;
/* Taille de police adaptée à la largeur disponible */
const fsAmt = Math.min(15, Math.max(10, gw / 7.5));
const fsName = Math.min(12, Math.max(8, gw / 9.5));
const fsPct = Math.min(11, Math.max(7, gw / 11));
const lineH = 15;
/* Seuils d'affichage */
const canShowAmt = gw > 36 && gh > 20;
/* Mode "paysage" : cellule plus large que haute */
const isLandscape = gw > gh;
/* Texte combiné "Nom — X %" : utilisé en paysage seulement s'il tient sur 1 ligne */
const combinedText = `${cell.name}${pct} %`;
const combinedCharW = fsName * 0.58;
const combinedFits = isLandscape && (combinedText.length * combinedCharW) <= (gw - 10);
/* Word-wrap du nom seul (mode portrait ou paysage sans place pour le combiné) */
const nameLines = (gw > 40 && gh > 30)
? wrapText(cell.name, gw - 10, fsName)
: [];
const canShowName = nameLines.length > 0;
/* Pourcentage séparé : s'assurer que la ligne % rentre dans le clipPath (PAD=4 de chaque côté) */
const CLIP_PAD = 4;
const textHeightSoFar = (canShowAmt ? lineH : 0) + (canShowName ? nameLines.length * lineH : 0);
const canShowPct = !combinedFits && gw > 44 && (gh - CLIP_PAD * 2) >= textHeightSoFar + lineH;
/* Pré-calcul des positions Y */
const textItems = [];
{
const slots = [];
if (canShowAmt) slots.push({ type: 'amt', h: lineH + 2 });
if (combinedFits) {
slots.push({ type: 'combined', text: combinedText, h: lineH });
} else {
if (canShowName) nameLines.forEach((l, li) => slots.push({ type: 'name', li, text: l, h: lineH }));
if (canShowPct) slots.push({ type: 'pct', h: lineH });
}
const totalH = slots.reduce((s, sl) => s + sl.h, 0);
let y = cy - totalH / 2 + lineH / 2;
for (const sl of slots) {
if (sl.type === 'amt') textItems.push({ key: 'amt', text: amt, fs: fsAmt, fw: '700', op: 1, y });
if (sl.type === 'combined') textItems.push({ key: 'combined', text: sl.text, fs: fsName, fw: '500', op: 0.92, y });
if (sl.type === 'name') textItems.push({ key: `n${sl.li}`, text: sl.text, fs: fsName, fw: '500', op: 0.92, y });
if (sl.type === 'pct') textItems.push({ key: 'pct', text: pct + ' %', fs: fsPct, fw: '400', op: 0.75, y });
y += sl.h;
}
}
return (
<g key={i} style={{ cursor: 'pointer' }}
onMouseMove={e => handleMouseMove(e, { ...cell, pct, amt }, i)}
>
{/* Cellule colorée */}
<rect x={gx} y={gy} width={gw} height={gh}
rx="5" fill={cell.color}
opacity={hoveredIdx !== null && !isHovered ? 0.45 : 0.9}
style={{ transition: 'opacity .15s' }}
/>
{/* Dégradé sombre */}
<rect x={gx} y={gy} width={gw} height={gh}
rx="5" fill="url(#dt-shadow)" opacity="0.35" />
{/* Bordure lumineuse au hover */}
{isHovered && (
<rect x={gx} y={gy} width={gw} height={gh}
rx="5" fill="none"
stroke="rgba(255,255,255,0.55)" strokeWidth="1.5" />
)}
{/* Labels */}
<g clipPath={`url(#clip-${i})`}>
{textItems.map(l => (
<text key={l.key} x={cx} y={l.y}
textAnchor="middle" dominantBaseline="middle"
fill="white"
fillOpacity={hoveredIdx !== null && !isHovered ? 0.3 : l.op}
fontSize={l.fs} fontWeight={l.fw}
fontFamily="system-ui,-apple-system,sans-serif"
filter="url(#dt-txt-shadow)"
style={{ transition: 'fill-opacity .15s' }}>
{l.text}
</text>
))}
</g>
</g>
);
})}
</svg>
{/* ── Tooltip ── */}
{tooltip && tipStyle && (
<div className="dist-tooltip" style={{ left: tipStyle.left, top: tipStyle.top }}>
<div className="dist-tooltip-dot" style={{ background: tooltip.cell.color }} />
<div>
<div className="dist-tooltip-amt">{tooltip.cell.amt}</div>
<div className="dist-tooltip-name">{tooltip.cell.name}</div>
<div className="dist-tooltip-pct">{tooltip.cell.pct} %</div>
</div>
</div>
)}
</div>
</div>
);
}
+591
View File
@@ -0,0 +1,591 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { useInteretsChart } from '../context/InteretsChartContext.jsx';
import { fmtEUR, fmtDate } from '../utils/format.js';
import Modal from './Modal.jsx';
const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
function hexToRgba(hex, a) {
if (!hex || hex.length < 7) return `rgba(79,168,232,${a})`;
const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16);
return `rgba(${r},${g},${b},${a})`;
}
const round2 = v => Math.round(v * 100) / 100;
/**
* DrillCellPanel
*
* Props :
* cell { platId, platNom, annee, mois, moisLabel } | null
* platId peut être null → toutes les plateformes
* onClose () => void (masqué si alwaysOpen=true)
* alwaysOpen bool — cache le bouton Fermer, ajuste le style
* pfuRates []
* activeView 'single'|'all'
* activeId number|null
* plateformes [] — pour le sélecteur (optionnel)
* investissements [] — pour le calcul bulk (optionnel)
* onBulkDone () => void — callback après validation en masse
*/
export default function DrillCellPanel({
cell, onClose, alwaysOpen = false,
pfuRates, activeView, activeId,
onEditRecu, onEditProjet, refreshKey,
investissements, plateformes, onBulkDone,
}) {
const {
inclureInterets, setInclureInterets,
inclureCapital, setInclureCapital,
inclureCashback, setInclureCashback,
netMode,
showActual, showProjected,
chartInterets, chartCapital, chartCashback,
} = useInteretsChart();
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
/* filterPlatId : '' = toutes, sinon l'id (string) de la plateforme sélectionnée dans le dropdown */
const [filterPlatId, setFilterPlatId] = useState('');
const [showRecus, setShowRecus] = useState(showActual);
const [showProjetes, setShowProjetes] = useState(showProjected);
/* ── Bulk validation ── */
const [bulkModal, setBulkModal] = useState(false);
const [bulkItems, setBulkItems] = useState([]);
const [bulkProcessing, setBulkProcessing] = useState(false);
const [bulkProgress, setBulkProgress] = useState(0);
const [bulkDone, setBulkDone] = useState(false);
/* ── Reset filtres quand la cellule change ── */
useEffect(() => {
if (!cell) { setData(null); return; }
setShowRecus(showActual);
setShowProjetes(showProjected);
/* Quand une cellule spécifique est cliquée, pré-sélectionner sa plateforme */
setFilterPlatId(cell.platId ? String(cell.platId) : '');
}, [cell?.platId, cell?.annee, cell?.mois]);
/* ── Fetch quand cellule OU filterPlatId change ── */
useEffect(() => {
if (!cell) { setData(null); return; }
setLoading(true);
setData(null);
const params = {
annee: cell.annee,
mois: cell.mois,
...(filterPlatId ? { plateforme_id: filterPlatId } : {}),
...(activeView === 'all' ? { scope: 'all' } : {}),
};
api.get('/dashboard/detail-cellule', params)
.then(d => setData(d))
.catch(() => setData({ recus: [], projetes: [] }))
.finally(() => setLoading(false));
}, [cell?.annee, cell?.mois, filterPlatId, activeView, activeId, refreshKey]);
/* ── Taux PFU pour l'année de la cellule ── */
const pfuReduction = useMemo(() => {
if (!pfuRates?.length || !cell) return 0;
const r = pfuRates.find(r => r.annee === cell.annee)
?? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]);
return (r.prelev_sociaux + r.impot_revenu) / 100;
}, [pfuRates, cell]);
/* ── Données filtrées (la plateforme est gérée côté fetch) ── */
const recus = useMemo(() => {
if (!data) return [];
return data.recus;
}, [data]);
const projetes = useMemo(() => {
if (!data) return [];
return data.projetes;
}, [data]);
if (!cell) return null;
/* ── Afficher la colonne Plateforme quand on est en mode "toutes" ── */
const showPlatCol = !filterPlatId;
const multiDetenteur = plateformes && new Set(plateformes.map(p => p.investisseur_id)).size > 1;
/* ── Calcul valeur ligne reçue ── */
const recuRowValue = (r) => {
let v = 0;
if (inclureInterets) v += netMode ? r.interets_nets : r.interets_bruts;
if (inclureCashback) v += r.cashback ?? 0;
if (inclureCapital) v += r.capital ?? 0;
return v;
};
/* ── Calcul valeur ligne projetée ── */
const projRowValue = (p) => {
let v = 0;
if (inclureInterets) v += netMode ? p.interets_prevus * (1 - pfuReduction) : p.interets_prevus;
if (inclureCapital) v += p.capital_prevu ?? 0;
return v;
};
/* ── Totaux ── */
const totalRecus = recus.reduce((s, r) => s + recuRowValue(r), 0);
const totalProjetes = projetes.reduce((s, p) => s + projRowValue(p), 0);
const grandTotal = (showRecus ? totalRecus : 0) + (showProjetes ? totalProjetes : 0);
/* ── Colonnes dynamiques ── */
const cols = [
inclureInterets && { key: 'interets', label: netMode ? 'Intérêts nets' : 'Intérêts bruts', color: chartInterets },
inclureCapital && { key: 'capital', label: 'Capital', color: chartCapital },
inclureCashback && { key: 'cashback', label: 'Cashback', color: chartCashback },
].filter(Boolean);
/* Date + Plateforme(opt) + Projet + Détenteur + cols + Total */
const colCount = 3 + (showPlatCol ? 1 : 0) + cols.length + 1;
/* Titre du header */
const headerTitle = filterPlatId
? `${plateformes?.find(p => String(p.id) === filterPlatId)?.nom ?? 'Plateforme'}${cell.moisLabel} ${cell.annee}`
: `Toutes les plateformes — ${cell.moisLabel} ${cell.annee}`;
/* ── Bulk : construction des payloads ── */
const openBulkModal = () => {
if (!projetes.length) return;
const items = projetes.map(p => {
const inv = investissements?.find(i => i.id === p.investissement_id);
const plat = inv ? plateformes?.find(pl => pl.id === inv.plateforme_id) : null;
const bruts = p.interets_prevus || 0;
const year = p.date_prevue ? Number(p.date_prevue.slice(0, 4)) : cell.annee;
const rates = pfuRates?.find(r => r.annee === year)
?? (pfuRates?.length ? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]) : null);
const methode = plat && plat.methode_remboursement !== 'choix_investisseur'
? plat.methode_remboursement
: (inv?.methode_remboursement || 'portefeuille');
const hasLocalTax = plat?.fiscalite === 'avec_fiscalite_locale' && plat?.taux_fiscalite_locale;
const taxe_locale = hasLocalTax ? round2(bruts * plat.taux_fiscalite_locale / 100) : 0;
const brutsApresLocal = hasLocalTax ? round2(bruts - taxe_locale) : bruts;
return {
_label: p.nom_projet,
_plat: p.plateforme_nom,
_date: p.date_prevue,
_capital: p.capital_prevu || 0,
_interets: bruts,
_total: (p.capital_prevu || 0) + bruts,
investissement_id: p.investissement_id,
date_remb: p.date_prevue,
capital: p.capital_prevu || 0,
cashback: 0,
interets_bruts_avant_local: hasLocalTax ? bruts : 0,
taxe_locale,
interets_bruts: brutsApresLocal,
prelev_sociaux: rates ? round2(brutsApresLocal * rates.prelev_sociaux / 100) : 0,
prelev_forfaitaire: rates ? round2(brutsApresLocal * rates.impot_revenu / 100) : 0,
statut: 'paye',
notes: '',
methode_remboursement: methode,
compte_id: methode === 'compte_courant' ? (inv?.compte_id || '') : '',
};
});
setBulkItems(items);
setBulkProgress(0);
setBulkDone(false);
setBulkProcessing(false);
setBulkModal(true);
};
const runBulk = async () => {
setBulkProcessing(true);
let done = 0;
for (const item of bulkItems) {
const { _label, _plat, _date, _capital, _interets, _total, ...payload } = item;
try { await api.post('/remboursements', payload); } catch (e) { /* continuer */ }
done++;
setBulkProgress(done);
}
setBulkProcessing(false);
setBulkDone(true);
if (onBulkDone) onBulkDone();
};
const closeBulkModal = () => {
setBulkModal(false);
setBulkItems([]);
setBulkProgress(0);
setBulkDone(false);
setBulkProcessing(false);
};
/* ── Rendu ── */
return (
<div style={{
margin: alwaysOpen ? '0 24px 28px' : '0 24px 28px',
padding: 0,
border: '2px solid var(--primary)',
borderRadius: 10,
overflow: 'hidden',
overflowX: 'auto',
background: 'var(--surface)',
}}>
{/* ── Header ── */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 16px',
background: 'var(--primary)',
color: '#fff',
}}>
<strong style={{ fontSize: '1rem' }}>{headerTitle}</strong>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{[
{ active: inclureInterets, toggle: () => setInclureInterets(v => !v), label: netMode ? 'Intérêts nets' : 'Intérêts bruts' },
{ active: inclureCapital, toggle: () => setInclureCapital(v => !v), label: 'Capital' },
{ active: inclureCashback, toggle: () => setInclureCashback(v => !v), label: 'Cashback' },
].map(btn => (
<button key={btn.label} onClick={() => btn.toggle()} style={{
border: `1px solid ${btn.active ? 'rgba(255,255,255,0.8)' : 'rgba(255,255,255,0.3)'}`,
background: btn.active ? 'rgba(255,255,255,0.2)' : 'transparent',
borderRadius: 6, padding: '3px 10px',
fontSize: '0.8rem', fontWeight: btn.active ? 700 : 400,
color: '#fff', cursor: 'pointer', transition: 'all .15s',
}}>{btn.label}</button>
))}
{!alwaysOpen && (
<button onClick={onClose} title="Fermer" style={{
background: 'rgba(255,255,255,0.2)', border: '1px solid rgba(255,255,255,0.4)',
borderRadius: 6, padding: '3px 10px', color: '#fff', cursor: 'pointer',
fontSize: '0.85rem', fontWeight: 700,
}}></button>
)}
</div>
</div>
{/* ── Barre filtres ── */}
<div style={{
display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
padding: '10px 16px', borderBottom: '1px solid var(--border)',
background: 'var(--surface-2)',
}}>
{/* Sélecteur plateforme */}
{plateformes && plateformes.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<label style={{ fontSize: '0.8rem', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Plateforme</label>
<select
value={filterPlatId}
onChange={e => setFilterPlatId(e.target.value)}
style={{ fontSize: 'var(--fs-sm)', padding: '3px 8px', borderRadius: 6,
border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)' }}>
<option value="">Toutes</option>
{plateformes.map(p => <option key={p.id} value={String(p.id)}>{p.nom}{multiDetenteur && p.investisseur_nom ? `${p.investisseur_nom}` : ''}</option>)}
</select>
</div>
)}
{/* Toggle Reçus / Projetés */}
<div style={{ display: 'inline-flex', background: 'var(--surface)', borderRadius: 8, padding: 3, gap: 2, marginLeft: 'auto' }}>
{[
{ key: 'recus', label: 'Reçus', active: showRecus, toggle: () => setShowRecus(v => !v) },
{ key: 'projetes', label: 'Projetés', active: showProjetes, toggle: () => setShowProjetes(v => !v) },
].map(btn => (
<button key={btn.key} onClick={() => btn.toggle()} style={{
border: 'none', cursor: 'pointer', padding: '4px 14px', borderRadius: 6,
fontSize: 'var(--fs-sm)',
fontWeight: btn.active ? 600 : 400,
background: btn.active ? 'var(--primary)' : 'transparent',
color: btn.active ? '#fff' : 'var(--text-muted)',
boxShadow: btn.active ? '0 1px 3px rgba(0,0,0,0.13)' : 'none',
transition: 'all .15s',
}}>{btn.label}</button>
))}
</div>
</div>
{/* ── Corps ── */}
{loading && (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>Chargement</div>
)}
{!loading && data && (
<div>
<table className="drill-table" style={{ width: '100%', minWidth: '100%', fontSize: 'var(--fs-sm)' }}>
<thead>
<tr style={{ background: 'var(--surface-2)', borderBottom: '1px solid var(--border)' }}>
<th style={thStyle}>Date</th>
{showPlatCol && <th style={thStyle}>Plateforme</th>}
<th style={{ ...thStyle, width: "100%" }}>Projet</th>
<th style={thStyle}>Détenteur</th>
{cols.map(c => (
<th key={c.key} style={{ ...thStyle, ...numStyle, color: c.color }}>{c.label}</th>
))}
<th style={{ ...thStyle, ...numStyle }}>Total ligne</th>
</tr>
</thead>
{/* ── Section Reçus ── */}
{showRecus && (
<tbody>
<tr>
<td colSpan={colCount} style={{
padding: '6px 12px', fontWeight: 700, fontSize: '0.78rem',
background: hexToRgba(chartInterets, 0.07),
color: 'var(--text-muted)', letterSpacing: '.04em', textTransform: 'uppercase',
}}>
Reçus ({recus.length})
</td>
</tr>
{recus.length === 0 ? (
<tr><td colSpan={colCount} style={{ padding: '10px 16px', color: 'var(--text-muted)', fontStyle: 'italic' }}>
Aucun remboursement reçu ce mois
</td></tr>
) : recus.map(r => (
<tr key={r.id}
style={{ borderBottom: '1px solid var(--border)', cursor: onEditRecu ? 'pointer' : 'default' }}
title={onEditRecu ? 'Modifier ce remboursement' : undefined}
onClick={() => onEditRecu && onEditRecu(r)}
>
<td style={tdStyle}>{fmtDate(r.date_remb)}</td>
{showPlatCol && <td style={{ ...tdStyle, fontWeight: 500 }}>{r.plateforme_nom || '—'}</td>}
<td style={tdStyle}>{r.nom_projet}</td>
<td style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: '0.8em' }}>{r.detenteur_nom || '—'}</td>
{cols.map(c => {
let v;
if (c.key === 'interets') v = netMode ? r.interets_nets : r.interets_bruts;
else if (c.key === 'capital') v = r.capital ?? 0;
else v = r.cashback ?? 0;
return <td key={c.key} style={{ ...tdStyle, ...numStyle }}>{fmtEUR(v)}</td>;
})}
<td style={{ ...tdStyle, ...numStyle, fontWeight: 600 }}>{fmtEUR(recuRowValue(r))}</td>
</tr>
))}
{recus.length > 0 && (
<tr className="drill-row-fixed" style={{ background: 'var(--surface-2)', fontWeight: 700 }}>
<td colSpan={3 + (showPlatCol ? 1 : 0)} style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: '0.8em' }}>
Sous-total reçus
</td>
{cols.map(c => {
const sum = recus.reduce((s, r) => {
if (c.key === 'interets') return s + (netMode ? r.interets_nets : r.interets_bruts);
if (c.key === 'capital') return s + (r.capital ?? 0);
return s + (r.cashback ?? 0);
}, 0);
return <td key={c.key} style={{ ...tdStyle, ...numStyle }}>{fmtEUR(sum)}</td>;
})}
<td style={{ ...tdStyle, ...numStyle }}>{fmtEUR(totalRecus)}</td>
</tr>
)}
</tbody>
)}
{/* ── Section Projetés ── */}
{showProjetes && (
<tbody>
<tr>
<td colSpan={colCount} style={{
padding: '6px 12px', fontWeight: 700, fontSize: '0.78rem',
background: 'rgba(148,163,184,0.08)',
color: 'var(--text-muted)', letterSpacing: '.04em', textTransform: 'uppercase',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>Projetés ({projetes.length})</span>
{projetes.length > 0 && (
<button
onClick={() => openBulkModal()}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
padding: '3px 10px', borderRadius: 5, cursor: 'pointer',
fontSize: '0.75rem', fontWeight: 600, letterSpacing: 'normal',
textTransform: 'none',
border: '1px solid var(--primary)',
background: 'var(--primary)', color: '#fff',
transition: 'opacity .15s',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
Valider en masse
</button>
)}
</div>
</td>
</tr>
{projetes.length === 0 ? (
<tr><td colSpan={colCount} style={{ padding: '10px 16px', color: 'var(--text-muted)', fontStyle: 'italic' }}>
Aucune projection ce mois
</td></tr>
) : projetes.map(p => (
<tr key={p.id}
style={{ borderBottom: '1px solid var(--border)', opacity: 0.85, cursor: onEditProjet ? 'pointer' : 'default' }}
title={onEditProjet ? 'Saisir ce remboursement' : undefined}
onClick={() => onEditProjet && onEditProjet(p)}
>
<td style={{ ...tdStyle, fontStyle: 'italic', color: 'var(--text-muted)' }}>{fmtDate(p.date_prevue)}</td>
{showPlatCol && <td style={{ ...tdStyle, fontStyle: 'italic', fontWeight: 500 }}>{p.plateforme_nom || '—'}</td>}
<td style={{ ...tdStyle, fontStyle: 'italic' }}>{p.nom_projet}</td>
<td style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: '0.8em' }}>{p.detenteur_nom || '—'}</td>
{cols.map(c => {
let v;
if (c.key === 'interets') v = netMode ? p.interets_prevus * (1 - pfuReduction) : p.interets_prevus;
else if (c.key === 'capital') v = p.capital_prevu ?? 0;
else v = 0;
return <td key={c.key} style={{ ...tdStyle, ...numStyle, fontStyle: 'italic', color: 'var(--text-muted)' }}>{fmtEUR(v)}</td>;
})}
<td style={{ ...tdStyle, ...numStyle, fontStyle: 'italic', color: 'var(--text-muted)' }}>{fmtEUR(projRowValue(p))}</td>
</tr>
))}
{projetes.length > 0 && (
<tr className="drill-row-fixed" style={{ background: 'var(--surface-2)', fontWeight: 700 }}>
<td colSpan={3 + (showPlatCol ? 1 : 0)} style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: '0.8em' }}>
Sous-total projetés
</td>
{cols.map(c => {
const sum = projetes.reduce((s, p) => {
if (c.key === 'interets') return s + (netMode ? p.interets_prevus * (1 - pfuReduction) : p.interets_prevus);
if (c.key === 'capital') return s + (p.capital_prevu ?? 0);
return s;
}, 0);
return <td key={c.key} style={{ ...tdStyle, ...numStyle }}>{fmtEUR(sum)}</td>;
})}
<td style={{ ...tdStyle, ...numStyle }}>{fmtEUR(totalProjetes)}</td>
</tr>
)}
</tbody>
)}
{/* ── Grand total ── */}
<tfoot>
<tr className="drill-row-fixed" style={{
background: 'var(--primary)', color: '#fff',
fontWeight: 700, fontSize: '0.9rem', pointerEvents: 'none',
}}>
<td colSpan={3 + (showPlatCol ? 1 : 0)} style={{ ...tdStyle, color: '#fff' }}>Total</td>
{cols.map(c => {
const sumR = showRecus ? recus.reduce((s, r) => {
if (c.key === 'interets') return s + (netMode ? r.interets_nets : r.interets_bruts);
if (c.key === 'capital') return s + (r.capital ?? 0);
return s + (r.cashback ?? 0);
}, 0) : 0;
const sumP = showProjetes ? projetes.reduce((s, p) => {
if (c.key === 'interets') return s + (netMode ? p.interets_prevus * (1 - pfuReduction) : p.interets_prevus);
if (c.key === 'capital') return s + (p.capital_prevu ?? 0);
return s;
}, 0) : 0;
return <td key={c.key} style={{ ...tdStyle, ...numStyle, color: '#fff' }}>{fmtEUR(sumR + sumP)}</td>;
})}
<td style={{ ...tdStyle, ...numStyle, color: '#fff', fontSize: '1rem' }}>
{fmtEUR(grandTotal)}
</td>
</tr>
</tfoot>
</table>
</div>
)}
{/* ── Modale validation en masse ── */}
{bulkModal && (
<Modal
open={bulkModal}
title={bulkDone ? 'Validation terminée' : `Valider ${bulkItems.length} remboursement${bulkItems.length > 1 ? 's' : ''}`}
onClose={bulkProcessing ? undefined : closeBulkModal}
width={620}
footer={
bulkDone ? (
<button className="btn-primary" onClick={() => closeBulkModal()}>Fermer</button>
) : bulkProcessing ? (
<span style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>
Traitement en cours {bulkProgress}/{bulkItems.length}
</span>
) : (
<>
<button className="btn-secondary" onClick={() => closeBulkModal()}>Annuler</button>
<button className="btn-primary" onClick={() => runBulk()}>
Enregistrer {bulkItems.length} remboursement{bulkItems.length > 1 ? 's' : ''}
</button>
</>
)
}
>
{bulkProcessing && (
<div style={{ marginBottom: 16 }}>
<div style={{ height: 6, borderRadius: 3, background: 'var(--border)', overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 3, background: 'var(--primary)',
width: `${bulkItems.length > 0 ? (bulkProgress / bulkItems.length) * 100 : 0}%`,
transition: 'width .3s ease',
}} />
</div>
</div>
)}
{bulkDone && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, padding: '12px 0' }}>
<div style={{ width: 48, height: 48, borderRadius: '50%', background: 'var(--success)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
</div>
<p style={{ margin: 0, fontWeight: 600, fontSize: '1rem' }}>
{bulkItems.length} remboursement{bulkItems.length > 1 ? 's' : ''} enregistré{bulkItems.length > 1 ? 's' : ''}
</p>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.88rem' }}>
{cell.moisLabel} {cell.annee}
</p>
</div>
)}
{!bulkDone && (
<>
<p style={{ margin: '0 0 12px', color: 'var(--text-muted)', fontSize: '0.88rem' }}>
Les remboursements suivants vont être créés d'après les projections de <strong>{cell.moisLabel} {cell.annee}</strong>.
Les prélèvements fiscaux sont estimés à partir des taux PFU de l'année.
</p>
<div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.88rem' }}>
<thead>
<tr style={{ background: 'var(--surface-2)', borderBottom: '1px solid var(--border)' }}>
{showPlatCol && <th style={thStyle}>Plateforme</th>}
<th style={{ ...thStyle, width: "100%" }}>Projet</th>
<th style={{ ...thStyle, ...numStyle }}>Date</th>
<th style={{ ...thStyle, ...numStyle }}>Capital</th>
<th style={{ ...thStyle, ...numStyle }}>Intérêts bruts</th>
<th style={{ ...thStyle, ...numStyle }}>Total brut</th>
</tr>
</thead>
<tbody>
{bulkItems.map((item, i) => (
<tr key={i} style={{ borderBottom: '1px solid var(--border)' }}>
{showPlatCol && <td style={tdStyle}>{item._plat || '—'}</td>}
<td style={tdStyle}>{item._label}</td>
<td style={{ ...tdStyle, ...numStyle }}>{fmtDate(item._date)}</td>
<td style={{ ...tdStyle, ...numStyle }}>{fmtEUR(item._capital)}</td>
<td style={{ ...tdStyle, ...numStyle }}>{fmtEUR(item._interets)}</td>
<td style={{ ...tdStyle, ...numStyle, fontWeight: 600 }}>{fmtEUR(item._total)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr style={{ background: 'var(--surface-2)', fontWeight: 700 }}>
<td colSpan={showPlatCol ? 5 : 4} style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: '0.8em' }}>Total</td>
<td style={{ ...tdStyle, ...numStyle }}>{fmtEUR(bulkItems.reduce((s, i) => s + i._total, 0))}</td>
</tr>
</tfoot>
</table>
</div>
</>
)}
</Modal>
)}
</div>
);
}
/* ── Styles partagés ── */
const thStyle = {
padding: '7px 12px',
textAlign: 'left',
fontWeight: 600,
fontSize: '0.78rem',
letterSpacing: '.03em',
color: 'var(--text-muted)',
whiteSpace: 'nowrap',
borderBottom: '1px solid var(--border)',
};
const tdStyle = { padding: '7px 12px', verticalAlign: 'middle', whiteSpace: 'nowrap' };
const numStyle = { textAlign: 'right' };
+285
View File
@@ -0,0 +1,285 @@
import { useMemo, useState, useRef } from 'react';
/* ── Constantes ─────────────────────────────────────────────── */
const COLOR = '#4fa8e8';
const BG = '#070c15';
const GRID = 'rgba(255,255,255,0.055)';
const LABEL = '#4a5568';
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.'];
/* ── Helpers ────────────────────────────────────────────────── */
function fmtK(v) {
if (v === 0) return '0 €';
const abs = Math.abs(v);
if (abs >= 1000) return (v / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 1 }) + ' 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: 2, maximumFractionDigits: 2 }) + ' €';
}
function fmtTodayFull() {
return new Date().toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' });
}
/* ── Composant ──────────────────────────────────────────────── */
export default function InteretsChart({ rows, netMode }) {
const [range, setRange] = useState('TOUT');
const [hover, setHover] = useState(null);
const svgRef = useRef(null);
const wrapRef = useRef(null);
/* ── 1. Cumul complet ── */
const allPoints = useMemo(() => {
if (!rows?.length) return [];
const byDate = {};
for (const r of rows) {
const d = r.date_remb.slice(0, 10);
const val = netMode ? (r.interets_nets || 0) : (r.interets_bruts || 0);
byDate[d] = (byDate[d] || 0) + val;
}
let cum = 0;
return Object.keys(byDate).sort().map(date => {
cum += byDate[date];
return { date, value: cum };
});
}, [rows, netMode]);
const todayStr = new Date().toISOString().slice(0, 10);
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 < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value });
return pts;
}
const now = new Date();
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 < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value });
return pts;
}, [allPoints, range, todayStr]);
/* ── 3. SVG dimensions ── */
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;
/* ── 4. É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 || 1;
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, plotW, plotH]);
/* ── 5. 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]);
/* ── 6. 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 });
};
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 displayDate = hover
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })
: fmtTodayFull();
const displayValue = hover ? fmtValueDisplay(hover.value) : fmtValueDisplay(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">{displayDate}</div>
<div className="solde-chart-value">{displayValue}</div>
</div>
<div className="solde-chart-controls" style={{ gap: 8 }}>
{/* Plages temporelles */}
<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="ig-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={COLOR} stopOpacity="0.22" />
<stop offset="70%" stopColor={COLOR} stopOpacity="0.06" />
<stop offset="100%" stopColor={COLOR} stopOpacity="0" />
</linearGradient>
<filter id="ig-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="rgba(255,255,255,0.18)" strokeWidth="1" />
)}
{/* Remplissage dégradé */}
<path d={areaPath} fill="url(#ig-fill)" />
{/* Ligne principale */}
<path d={linePath} fill="none" stroke={COLOR} strokeWidth="1"
filter="url(#ig-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>
);
})}
{/* Ligne verticale + point hover */}
{hover && (
<g>
<line x1={hover.x} y1={PAD.top} x2={hover.x} y2={PAD.top + plotH}
stroke="rgba(255,255,255,0.12)" strokeWidth="1" strokeDasharray="3 4" />
<circle cx={hover.x} cy={hover.y} r="4.5"
fill={COLOR} stroke={BG} strokeWidth="2" />
</g>
)}
</svg>
)}
{/* ── Tooltip ── */}
{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>
);
}
@@ -0,0 +1,255 @@
import { useMemo, useState, useRef, useCallback } from 'react';
/* ── Palette ────────────────────────────────────────────────── */
const PALETTE = [
'#e8547a', '#9333ea', '#0d9488', '#f59e0b',
'#3b82f6', '#10b981', '#f97316', '#06b6d4',
'#a855f7', '#84cc16', '#ec4899', '#14b8a6',
];
/* ── Algorithme treemap (binary split équilibré) ─────────────── */
function buildTreemap(items, x, y, w, h) {
if (!items.length) return [];
if (items.length === 1) return [{ ...items[0], x, y, w, h }];
const total = items.reduce((s, i) => s + i.value, 0);
let best = 1, bestDiff = Infinity, acc = 0;
for (let i = 0; i < items.length - 1; i++) {
acc += items[i].value;
const diff = Math.abs(acc - (total - acc));
if (diff < bestDiff) { bestDiff = diff; best = i + 1; }
}
const g1 = items.slice(0, best);
const g2 = items.slice(best);
const r1 = g1.reduce((s, i) => s + i.value, 0) / total;
if (w >= h) {
const w1 = w * r1;
return [...buildTreemap(g1, x, y, w1, h), ...buildTreemap(g2, x + w1, y, w - w1, h)];
} else {
const h1 = h * r1;
return [...buildTreemap(g1, x, y, w, h1), ...buildTreemap(g2, x, y + h1, w, h - h1)];
}
}
function fmtAmount(v) {
return Math.round(v).toLocaleString('fr-FR') + ' €';
}
function wrapText(text, maxWidth, fontSize) {
const charW = fontSize * 0.58;
const maxChars = Math.max(1, Math.floor(maxWidth / charW));
const words = text.split(' ');
const lines = [];
let current = '';
for (const word of words) {
const candidate = current ? current + ' ' + word : word;
if (candidate.length <= maxChars) {
current = candidate;
} else {
if (current) lines.push(current);
current = word.length > maxChars ? word.slice(0, maxChars - 1) + '…' : word;
}
}
if (current) lines.push(current);
return lines.slice(0, 3);
}
/* ── Composant ──────────────────────────────────────────────── */
export default function InteretsDistributionChart({ rows, netMode }) {
const [hoveredIdx, setHoveredIdx] = useState(null);
const [tooltip, setTooltip] = useState(null);
const svgRef = useRef(null);
const wrapRef = useRef(null);
/* Intérêts cumulés par plateforme */
const data = useMemo(() => {
if (!rows?.length) return [];
const byPlat = {};
for (const r of rows) {
const key = r.plateforme_nom || `#${r.plateforme_id}`;
const val = netMode ? (r.interets_nets || 0) : (r.interets_bruts || 0);
byPlat[key] = (byPlat[key] || 0) + val;
}
return Object.entries(byPlat)
.filter(([, v]) => v > 0)
.sort(([, a], [, b]) => b - a)
.map(([name, value], i) => ({ name, value, color: PALETTE[i % PALETTE.length] }));
}, [rows, netMode]);
const total = data.reduce((s, i) => s + i.value, 0);
const W = 440, H = 290, GAP = 3;
const cells = useMemo(() => buildTreemap(data, 0, 0, W, H), [data]);
const handleMouseMove = useCallback((e, cell, idx) => {
if (!wrapRef.current) return;
const wrapRect = wrapRef.current.getBoundingClientRect();
setHoveredIdx(idx);
setTooltip({ x: e.clientX - wrapRect.left, y: e.clientY - wrapRect.top, cell });
}, []);
const handleMouseLeave = useCallback(() => {
setHoveredIdx(null);
setTooltip(null);
}, []);
if (!cells.length) return null;
const TIP_W = 160, TIP_H = 66;
const tipStyle = tooltip ? (() => {
const ww = wrapRef.current?.offsetWidth || 440;
const wh = wrapRef.current?.offsetHeight || 360;
let tx = tooltip.x + 14;
let ty = tooltip.y - TIP_H / 2;
if (tx + TIP_W > ww - 4) tx = tooltip.x - TIP_W - 14;
if (ty < 4) ty = 4;
if (ty + TIP_H > wh - 4) ty = wh - TIP_H - 4;
return { left: tx, top: ty };
})() : null;
const modeLabel = netMode ? 'Nets' : 'Bruts';
return (
<div className="dist-chart-wrap" ref={wrapRef}>
{/* ── En-tête ── */}
<div className="dist-chart-header">
<span className="dist-chart-title">Intérêts {modeLabel}</span>
<div className="dist-dropdown">
par Plateforme
<svg width="10" height="10" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9l6 6 6-6"/>
</svg>
</div>
</div>
{/* ── Treemap SVG ── */}
<div style={{ position: 'relative' }}>
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
style={{ width: '100%', height: 'auto', display: 'block' }}
onMouseLeave={handleMouseLeave}
>
<defs>
<linearGradient id="idt-shadow" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#000" stopOpacity="0" />
<stop offset="100%" stopColor="#000" stopOpacity="0.4" />
</linearGradient>
<filter id="idt-txt-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="1" stdDeviation="1.5" floodColor="#000" floodOpacity="0.55"/>
</filter>
{cells.map((cell, i) => {
const PAD = 4;
const gx = cell.x + GAP / 2 + PAD;
const gy = cell.y + GAP / 2 + PAD;
const gw = Math.max(cell.w - GAP - PAD * 2, 0);
const gh = Math.max(cell.h - GAP - PAD * 2, 0);
return (
<clipPath key={i} id={`idt-clip-${i}`}>
<rect x={gx} y={gy} width={gw} height={gh} />
</clipPath>
);
})}
</defs>
{cells.map((cell, i) => {
const gx = cell.x + GAP / 2;
const gy = cell.y + GAP / 2;
const gw = Math.max(cell.w - GAP, 0);
const gh = Math.max(cell.h - GAP, 0);
const cx = gx + gw / 2;
const cy = gy + gh / 2;
const pct = ((cell.value / total) * 100).toFixed(0);
const amt = fmtAmount(cell.value);
const isHovered = hoveredIdx === i;
const fsAmt = Math.min(15, Math.max(10, gw / 7.5));
const fsName = Math.min(12, Math.max(8, gw / 9.5));
const fsPct = Math.min(11, Math.max(7, gw / 11));
const lineH = 15;
const canShowAmt = gw > 36 && gh > 20;
const isLandscape = gw > gh;
const combinedText = `${cell.name}${pct} %`;
const combinedCharW = fsName * 0.58;
const combinedFits = isLandscape && (combinedText.length * combinedCharW) <= (gw - 10);
const nameLines = (gw > 40 && gh > 30) ? wrapText(cell.name, gw - 10, fsName) : [];
const canShowName = nameLines.length > 0;
const CLIP_PAD = 4;
const textHeightSoFar = (canShowAmt ? lineH : 0) + (canShowName ? nameLines.length * lineH : 0);
const canShowPct = !combinedFits && gw > 44 && (gh - CLIP_PAD * 2) >= textHeightSoFar + lineH;
const textItems = [];
{
const slots = [];
if (canShowAmt) slots.push({ type: 'amt', h: lineH + 2 });
if (combinedFits) {
slots.push({ type: 'combined', text: combinedText, h: lineH });
} else {
if (canShowName) nameLines.forEach((l, li) => slots.push({ type: 'name', li, text: l, h: lineH }));
if (canShowPct) slots.push({ type: 'pct', h: lineH });
}
const totalH = slots.reduce((s, sl) => s + sl.h, 0);
let y = cy - totalH / 2 + lineH / 2;
for (const sl of slots) {
if (sl.type === 'amt') textItems.push({ key: 'amt', text: amt, fs: fsAmt, fw: '700', op: 1, y });
if (sl.type === 'combined') textItems.push({ key: 'combined', text: sl.text, fs: fsName, fw: '500', op: 0.92, y });
if (sl.type === 'name') textItems.push({ key: `n${sl.li}`, text: sl.text, fs: fsName, fw: '500', op: 0.92, y });
if (sl.type === 'pct') textItems.push({ key: 'pct', text: pct + ' %', fs: fsPct, fw: '400', op: 0.75, y });
y += sl.h;
}
}
return (
<g key={i} style={{ cursor: 'pointer' }}
onMouseMove={e => handleMouseMove(e, { ...cell, pct, amt }, i)}
>
<rect x={gx} y={gy} width={gw} height={gh}
rx="5" fill={cell.color}
opacity={hoveredIdx !== null && !isHovered ? 0.45 : 0.9}
style={{ transition: 'opacity .15s' }}
/>
<rect x={gx} y={gy} width={gw} height={gh}
rx="5" fill="url(#idt-shadow)" opacity="0.35" />
{isHovered && (
<rect x={gx} y={gy} width={gw} height={gh}
rx="5" fill="none"
stroke="rgba(255,255,255,0.55)" strokeWidth="1.5" />
)}
<g clipPath={`url(#idt-clip-${i})`}>
{textItems.map(l => (
<text key={l.key} x={cx} y={l.y}
textAnchor="middle" dominantBaseline="middle"
fill="white"
fillOpacity={hoveredIdx !== null && !isHovered ? 0.3 : l.op}
fontSize={l.fs} fontWeight={l.fw}
fontFamily="system-ui,-apple-system,sans-serif"
filter="url(#idt-txt-shadow)"
style={{ transition: 'fill-opacity .15s' }}>
{l.text}
</text>
))}
</g>
</g>
);
})}
</svg>
{/* ── Tooltip ── */}
{tooltip && tipStyle && (
<div className="dist-tooltip" style={{ left: tipStyle.left, top: tipStyle.top }}>
<div className="dist-tooltip-dot" style={{ background: tooltip.cell.color }} />
<div>
<div className="dist-tooltip-amt">{tooltip.cell.amt}</div>
<div className="dist-tooltip-name">{tooltip.cell.name}</div>
<div className="dist-tooltip-pct">{tooltip.cell.pct} %</div>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,456 @@
import { useMemo, useState, useRef } from 'react';
import { useInteretsChart } from '../context/InteretsChartContext.jsx';
function fmtTotal(v) {
return v.toLocaleString('fr-FR',{minimumFractionDigits:2,maximumFractionDigits:2})+' €';
}
function fmtCenter(v) {
if (!v||v===0) return '—';
return v.toLocaleString('fr-FR',{minimumFractionDigits:2,maximumFractionDigits:2})+' €';
}
/* ── Helpers SVG ──────────────────────────────────────────────── */
function polar(cx,cy,r,deg) {
const rad=(deg-90)*Math.PI/180;
return {x:cx+r*Math.cos(rad), y:cy+r*Math.sin(rad)};
}
function roundedArcPath(cx, cy, outerR, innerR, startDeg, endDeg) {
const span = endDeg - startDeg;
if (span < 0.1) return '';
const f = n => n.toFixed(2);
if (span >= 359.9) {
const o0=polar(cx,cy,outerR,0), o1=polar(cx,cy,outerR,180);
const i0=polar(cx,cy,innerR,0), i1=polar(cx,cy,innerR,180);
return [
`M${f(o0.x)} ${f(o0.y)} A${outerR} ${outerR} 0 1 1 ${f(o1.x)} ${f(o1.y)}`,
`A${outerR} ${outerR} 0 1 1 ${f(o0.x)} ${f(o0.y)} Z`,
`M${f(i0.x)} ${f(i0.y)} A${innerR} ${innerR} 0 1 1 ${f(i1.x)} ${f(i1.y)}`,
`A${innerR} ${innerR} 0 1 1 ${f(i0.x)} ${f(i0.y)} Z`,
].join(' ');
}
const capR = (outerR - innerR) / 2;
const lg = span > 180 ? 1 : 0;
const oS=polar(cx,cy,outerR,startDeg), oE=polar(cx,cy,outerR,endDeg);
const iS=polar(cx,cy,innerR,startDeg), iE=polar(cx,cy,innerR,endDeg);
return [
`M${f(oS.x)} ${f(oS.y)}`,
`A${outerR} ${outerR} 0 ${lg} 1 ${f(oE.x)} ${f(oE.y)}`,
`A${capR} ${capR} 0 0 1 ${f(iE.x)} ${f(iE.y)}`,
`A${innerR} ${innerR} 0 ${lg} 0 ${f(iS.x)} ${f(iS.y)}`,
`A${capR} ${capR} 0 0 0 ${f(oS.x)} ${f(oS.y)}`,
'Z',
].join(' ');
}
/* ── Dimensions donut ─────────────────────────────────────────── */
const CX=120, CY=120;
const OUTER_R=108, INNER_R=72;
export default function InteretsDonutChart() {
const {
annee,
inclureInterets, setInclureInterets,
inclureCapital, setInclureCapital,
inclureCashback, setInclureCashback,
selectedMonth, setSelectedMonth,
showActual, showProjected,
selectActualOnly, selectProjectedOnly, setActualProjected,
months,
modeGlobal,
selectedYear, setSelectedYear,
years,
chartInterets, chartCapital, chartCashback,
netMode,
} = useInteretsChart();
const [hoveredArcIdx, setHoveredArcIdx] = useState(null);
const [hoveredLegIdx, setHoveredLegIdx] = useState(null);
const [centerHovered, setCenterHovered] = useState(false);
/* ── Mémoire de filtrage ─────────────────────────────────────── */
const prevStateRef = useRef(null);
const [hasPrev, setHasPrev] = useState(false);
const activeCount = [inclureCapital, inclureCashback, inclureInterets].filter(Boolean).length;
/* ── Construire un item donut ────────────────────────────────── */
const makeItem = (label, color, opacity, actualAmt, projectedAmt) => ({
label, color, opacity,
value: actualAmt + projectedAmt,
actualAmt, projectedAmt,
});
/* ── Source de données — ordre fixe : Intérêts → Capital → Cashback ── */
const donutData = useMemo(() => {
if (activeCount===0) return [];
if (modeGlobal) {
const src = selectedYear !== null ? years.find(y => y.y === selectedYear) : null;
const sumA = key => showActual ? (src ? (src[key]||0) : years.reduce((s,y) => s+(y[key]||0), 0)) : 0;
const sumP = key => showProjected ? (src ? (src[key]||0) : years.reduce((s,y) => s+(y[key]||0), 0)) : 0;
if (activeCount===1) {
if (inclureInterets) {
const actual=sumA('interetsAmt'), proj=sumP('interetsProjAmt'), s=[];
if(actual>0) s.push(makeItem('Reçu', chartInterets, 0.88, actual, 0));
if(proj >0) s.push(makeItem('Projeté', chartInterets, 0.30, proj, 0));
return s;
}
if (inclureCapital) {
const actual=sumA('capitalAmt'), proj=sumP('capitalProjAmt'), s=[];
if(actual>0) s.push(makeItem('Reçu', chartCapital, 0.88, actual, 0));
if(proj >0) s.push(makeItem('Projeté', chartCapital, 0.30, proj, 0));
return s;
}
if (inclureCashback) {
const v=sumA('cashbackAmt');
return v>0?[makeItem('Reçu', chartCashback, 0.88, v, 0)]:[];
}
}
// Multi-types — ordre : Intérêts → Capital → Cashback
const s=[];
if(inclureInterets){
const a=sumA('interetsAmt'), p=sumP('interetsProjAmt');
if(a+p>0) s.push(makeItem(netMode?'Intérêts nets':'Intérêts bruts', chartInterets, 0.88, a, p));
}
if(inclureCapital){
const a=sumA('capitalAmt'), p=sumP('capitalProjAmt');
if(a+p>0) s.push(makeItem('Capital', chartCapital, 0.88, a, p));
}
if(inclureCashback){
const a=sumA('cashbackAmt');
if(a>0) s.push(makeItem('Cashback', chartCashback, 0.88, a, 0));
}
return s;
}
// Mode mensuel
const src=selectedMonth!==null?months[selectedMonth]:null;
const sumA=key=>showActual ?(src?(src[key]||0):months.reduce((s,m)=>s+(m[key]||0),0)):0;
const sumP=key=>showProjected ?(src?(src[key]||0):months.reduce((s,m)=>s+(m[key]||0),0)):0;
if (activeCount===1) {
if (inclureInterets) {
const actual=sumA('interetsAmt'), proj=sumP('interetsProjAmt'), s=[];
if(actual>0) s.push(makeItem('Reçu', chartInterets, 0.88, actual, 0));
if(proj >0) s.push(makeItem('Projeté', chartInterets, 0.30, proj, 0));
return s;
}
if (inclureCapital) {
const actual=sumA('capitalAmt'), proj=sumP('capitalProjAmt'), s=[];
if(actual>0) s.push(makeItem('Reçu', chartCapital, 0.88, actual, 0));
if(proj >0) s.push(makeItem('Projeté', chartCapital, 0.30, proj, 0));
return s;
}
if (inclureCashback) {
const v=sumA('cashbackAmt');
return v>0?[makeItem('Reçu', chartCashback, 0.88, v, 0)]:[];
}
}
// Multi-types — ordre : Intérêts → Capital → Cashback
const s=[];
if(inclureInterets){
const a=sumA('interetsAmt'), p=sumP('interetsProjAmt');
if(a+p>0) s.push(makeItem(netMode?'Intérêts nets':'Intérêts bruts', chartInterets, 0.88, a, p));
}
if(inclureCapital){
const a=sumA('capitalAmt'), p=sumP('capitalProjAmt');
if(a+p>0) s.push(makeItem('Capital', chartCapital, 0.88, a, p));
}
if(inclureCashback){
const a=sumA('cashbackAmt');
if(a>0) s.push(makeItem('Cashback', chartCashback, 0.88, a, 0));
}
return s;
},[months,selectedMonth,inclureCapital,inclureCashback,inclureInterets,
showActual,showProjected,chartCapital,chartCashback,chartInterets,
modeGlobal,years,selectedYear,activeCount]);
const total=donutData.reduce((s,d)=>s+d.value,0);
const GAP = donutData.length <= 1 ? 0 : 4;
const arcs = useMemo(() => {
const out=[]; let cur=0;
donutData.forEach(d => {
const sweep = total > 0 ? (d.value / total) * 360 : 0;
const s = cur + GAP/2, e = cur + sweep - GAP/2;
if (e - s > 0.1) out.push({...d, startDeg:s, endDeg:e});
cur += sweep;
});
return out;
},[donutData,total,GAP]);
/* ── Clic sur un quartier ou une ligne de légende ───────────── */
const handleArcClick = (item) => {
prevStateRef.current = {
inclureInterets, inclureCapital, inclureCashback,
showActual, showProjected,
};
setHasPrev(true);
if (activeCount > 1) {
setInclureCapital(item.label === 'Capital');
setInclureCashback(item.label === 'Cashback');
setInclureInterets(item.label === 'Intérêts nets' || item.label === 'Intérêts bruts');
} else {
if (item.label === 'Reçu') selectActualOnly();
if (item.label === 'Projeté') selectProjectedOnly();
}
};
/* ── Retour arrière via clic centre ──────────────────────────── */
const handleCenterClick = () => {
if (!hasPrev || !prevStateRef.current) return;
const ps = prevStateRef.current;
setInclureCapital(ps.inclureCapital);
setInclureCashback(ps.inclureCashback);
setInclureInterets(ps.inclureInterets);
setActualProjected(ps.showActual, ps.showProjected);
prevStateRef.current = null;
setHasPrev(false);
};
/* ── Tooltip positionné sur le point extérieur de l'arc ────── */
const tooltipArc = hoveredArcIdx !== null ? arcs[hoveredArcIdx] : null;
const tooltipPos = tooltipArc ? (() => {
const midAngle = (tooltipArc.startDeg + tooltipArc.endDeg) / 2;
const tipPt = polar(CX, CY, OUTER_R + 12, midAngle);
return {
pctX: (tipPt.x / 240) * 100,
pctY: (tipPt.y / 240) * 100,
onRight: tipPt.x >= CX,
onBottom: tipPt.y >= CY,
};
})() : null;
/* ── Sous-détail reçu/projeté pour la légende ───────────────── */
const getLegendDetail = (d) => {
if (activeCount <= 1) return null;
if (d.actualAmt > 0 && d.projectedAmt > 0) {
if (d.label !== 'Intérêts nets' && d.label !== 'Intérêts bruts' && d.label !== 'Capital') return null;
return `dont ${fmtTotal(d.actualAmt)} reçus · ${fmtTotal(d.projectedAmt)} projetés`;
}
return null;
};
/* ── Labels / sélection ──────────────────────────────────────── */
const centerLabel = modeGlobal
? (selectedYear !== null ? String(selectedYear) : 'Total')
: (selectedMonth !== null ? months[selectedMonth].label : 'Total');
const headerSub = modeGlobal
? (selectedYear !== null ? String(selectedYear) : 'Toutes les années')
: (selectedMonth !== null ? `${months[selectedMonth].labelLong} ${annee}` : String(annee));
const hasSelection = modeGlobal ? selectedYear !== null : selectedMonth !== null;
const clearSelection = () => {
if (modeGlobal) setSelectedYear(null);
else setSelectedMonth(null);
};
return (
<div className="solde-chart-wrap" style={{
padding:'20px 20px 16px',
height:'100%', boxSizing:'border-box',
display:'flex', flexDirection:'column',
}}>
{/* ── SVG donut ── */}
<div style={{flex:1,display:'flex',alignItems:'center',justifyContent:'center',minHeight:0,padding:'4px 0'}}>
<div style={{position:'relative',width:'100%',maxWidth:260}}
onMouseLeave={()=>{ setHoveredArcIdx(null); setCenterHovered(false); }}
>
<svg viewBox="0 0 240 240" style={{width:'100%',height:'auto',display:'block'}}>
{/* Anneau de fond */}
<circle cx={CX} cy={CY} r={(OUTER_R+INNER_R)/2} fill="none"
stroke="var(--border)" strokeWidth={OUTER_R-INNER_R} opacity={0.18}/>
{/* Segments */}
{total>0 && arcs.map((arc,i)=>(
<path key={i}
d={roundedArcPath(CX,CY,OUTER_R,INNER_R,arc.startDeg,arc.endDeg)}
fill={arc.color}
fillRule={arc.endDeg-arc.startDeg>=359.9?'evenodd':undefined}
opacity={hoveredArcIdx===i
? Math.min(1,(arc.opacity??0.88)+0.12)
: (arc.opacity??0.88)}
style={{cursor:'pointer',transition:'opacity .12s'}}
onMouseEnter={()=>setHoveredArcIdx(i)}
onClick={()=>handleArcClick(arc)}
/>
))}
{/* Zone cliquable centre */}
<circle cx={CX} cy={CY} r={INNER_R - 2}
fill={centerHovered && hasPrev ? 'var(--primary)' : 'transparent'}
fillOpacity={centerHovered && hasPrev ? 0.07 : 0}
style={{ cursor: hasPrev ? 'pointer' : 'default', transition:'fill-opacity .15s' }}
onMouseEnter={()=>setCenterHovered(true)}
onMouseLeave={()=>setCenterHovered(false)}
onClick={handleCenterClick}
/>
{/* Indicateur retour arrière */}
{hasPrev && (
<text x={CX} y={CY-22} textAnchor="middle" fontSize="13"
fill="var(--primary)" fontFamily="system-ui,sans-serif"
opacity={centerHovered ? 1 : 0.55}
style={{transition:'opacity .15s', pointerEvents:'none'}}>
</text>
)}
{/* Texte central */}
<text x={CX} y={hasPrev ? CY-4 : CY-9}
textAnchor="middle" fontSize="12"
fill={hasPrev && centerHovered ? 'var(--primary)' : 'var(--text-muted)'}
fontFamily="system-ui,sans-serif" fontWeight="400"
style={{transition:'fill .15s', pointerEvents:'none'}}>
{centerLabel}
</text>
<text x={CX} y={hasPrev ? CY+14 : CY+13}
textAnchor="middle" fontSize="18"
fill={hasPrev && centerHovered ? 'var(--primary)' : 'var(--text)'}
fontWeight="700" fontFamily="system-ui,sans-serif"
style={{transition:'fill .15s', pointerEvents:'none'}}>
{fmtCenter(total)}
</text>
</svg>
{/* ── Tooltip arc ── */}
{tooltipArc && tooltipPos && (
<div style={{
position:'absolute',
...(tooltipPos.onRight
? { left:`${tooltipPos.pctX}%`, transform:'translateX(8px)' }
: { right:`${100-tooltipPos.pctX}%`, transform:'translateX(-8px)' }),
...(tooltipPos.onBottom
? { top:`${tooltipPos.pctY}%` }
: { bottom:`${100-tooltipPos.pctY}%` }),
pointerEvents:'none', zIndex:30,
}}>
<div className="sg-tooltip">
<span className="sg-tooltip-date" style={{display:'flex',alignItems:'center',gap:6}}>
<span style={{display:'inline-block',width:8,height:8,borderRadius:2,
background:tooltipArc.color,opacity:tooltipArc.opacity??0.88,flexShrink:0}}/>
{tooltipArc.label}
</span>
<span style={{fontSize:'var(--fs-sm)',color:'var(--text)',fontWeight:700}}>
{fmtTotal(tooltipArc.value)}
</span>
<span style={{fontSize:'var(--fs-xs)',color:'var(--text-muted)'}}>
{total>0 ? ((tooltipArc.value/total)*100).toFixed(1)+' %' : '—'}
</span>
{(activeCount > 1 || tooltipArc.label === 'Reçu' || tooltipArc.label === 'Projeté') && (
<span style={{
fontSize:'var(--fs-xs)',color:'var(--text-muted)',opacity:0.6,
marginTop:2,borderTop:'1px solid var(--border)',paddingTop:4,
}}>
Cliquer pour {activeCount > 1 ? 'isoler' : 'sélectionner'}
</span>
)}
</div>
</div>
)}
{/* ── Tooltip centre ── */}
{centerHovered && hasPrev && (
<div style={{
position:'absolute', left:'50%', top:'50%',
transform:'translate(-50%, calc(-100% - 10px))',
pointerEvents:'none', zIndex:30,
}}>
<div className="sg-tooltip" style={{textAlign:'center'}}>
<span style={{fontSize:'var(--fs-xs)',color:'var(--primary)',fontWeight:600}}>
Restaurer le filtrage précédent
</span>
</div>
</div>
)}
</div>
</div>
{/* ── Légende ── */}
<div style={{marginTop:4}}>
<div style={{
display:'flex', alignItems:'center', justifyContent:'space-between',
marginBottom:10, paddingBottom:10,
borderBottom:'1px solid var(--border)',
}}>
<span style={{fontSize:'var(--fs-sm)',fontWeight:600,color:'var(--text)'}}>Répartition</span>
<span
style={{
fontSize:'var(--fs-xs)',
color:hasSelection?'var(--primary)':'var(--text-muted)',
cursor:hasSelection?'pointer':'default',
display:'inline-flex', alignItems:'center', gap:4,
}}
onClick={hasSelection?()=>clearSelection():undefined}
title={hasSelection?'Revenir à la vue globale':undefined}
>
{headerSub}
{hasSelection&&<span style={{fontSize:11,opacity:0.7}}>×</span>}
</span>
</div>
{donutData.length===0 && (
<div style={{fontSize:'var(--fs-xs)',color:'var(--text-muted)',textAlign:'center',padding:'10px 0'}}>
Aucun type sélectionné
</div>
)}
{donutData.map((d,i)=>{
const detail = getLegendDetail(d);
const isClickable = activeCount > 1 || d.label === 'Reçu' || d.label === 'Projeté';
const isHov = hoveredLegIdx === i;
return (
<div key={i}
onClick={isClickable ? ()=>handleArcClick(d) : undefined}
onMouseEnter={isClickable ? ()=>setHoveredLegIdx(i) : undefined}
onMouseLeave={isClickable ? ()=>setHoveredLegIdx(null) : undefined}
style={{
display:'flex', alignItems:'center', gap:8,
padding:'7px 6px',
marginLeft:-6, marginRight:-6,
borderTop: i>0 ? '1px solid var(--border)' : 'none',
cursor: isClickable ? 'pointer' : 'default',
borderRadius:6,
background: isHov ? 'var(--surface-2)' : 'transparent',
transition:'background .12s',
}}
>
<span style={{
width:10,height:10,borderRadius:2,flexShrink:0,
background:d.color,opacity:d.opacity??0.88,
}}/>
<div style={{flex:1,minWidth:0,display:'flex',alignItems:'baseline',gap:6,flexWrap:'wrap'}}>
<span style={{fontSize:'var(--fs-xs)',color:'var(--text-muted)',fontWeight:400,whiteSpace:'nowrap'}}>
{d.label}
</span>
<span style={{fontSize:'var(--fs-sm)',color:'var(--text)',fontWeight:700,whiteSpace:'nowrap'}}>
{fmtTotal(d.value)}
</span>
{detail && (
<span style={{fontSize:11,color:'var(--text-muted)',opacity:0.8,lineHeight:1.3}}>
{detail}
</span>
)}
</div>
<span style={{fontSize:'var(--fs-xs)',color:'var(--text)',fontWeight:500,flexShrink:0,minWidth:38,textAlign:'right'}}>
{total>0?((d.value/total)*100).toFixed(1)+' %':'—'}
</span>
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,542 @@
import { useState, useRef, useEffect } from 'react';
import { api } from '../api.js';
import { useInteretsChart } from '../context/InteretsChartContext.jsx';
const ICONS_BASE = '/api/icons-files/';
const GRID = 'rgba(255,255,255,0.055)';
const LABEL = '#4a5568';
const MOIS = ['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc'];
function fmtShort(v) {
if (!v || v === 0) return '';
const abs = Math.abs(v);
if (abs >= 1000) return (v/1000).toLocaleString('fr-FR',{maximumFractionDigits:1})+'k €';
return v.toLocaleString('fr-FR',{minimumFractionDigits:1,maximumFractionDigits:1})+' €';
}
function fmtTotal(v) {
return v.toLocaleString('fr-FR',{minimumFractionDigits:2,maximumFractionDigits:2})+' €';
}
function hexToRgba(hex, a) {
if (!hex||hex.length<7) return `rgba(79,168,232,${a})`;
const r=parseInt(hex.slice(1,3),16), g=parseInt(hex.slice(3,5),16), b=parseInt(hex.slice(5,7),16);
return `rgba(${r},${g},${b},${a})`;
}
export default function InteretsMensuelsChart() {
const {
annee, setAnnee, availableYears,
inclureInterets, setInclureInterets,
inclureCapital, setInclureCapital,
inclureCashback, setInclureCashback,
selectedMonth, setSelectedMonth,
showActual, toggleActual,
showProjected, toggleProjected,
months, annualTotal,
// Mode global (TOUT)
modeGlobal, toggleModeGlobal,
selectedYear, setSelectedYear,
years, globalTotal,
netMode,
chartInterets, chartCapital, chartCashback,
} = useInteretsChart();
// État local uniquement
const [hovered, setHovered] = useState(null);
const [windowStart, setWindowStart] = useState(0);
const [libIcons, setLibIcons] = useState({});
const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.innerWidth < 768);
const [sheetOpen, setSheetOpen] = useState(false);
const initializedRef = useRef(false);
useEffect(() => {
api.get('/icons').then(rows => {
const m = {};
rows.forEach(r => { m[r.name] = r.filename; });
setLibIcons(m);
}).catch(() => {});
}, []);
useEffect(() => {
const onResize = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
// Réinitialiser le hovered au changement de mode
useEffect(() => { setHovered(null); }, [modeGlobal]);
const visibleYears = availableYears.length ? availableYears.slice(windowStart, windowStart+3) : [annee];
const canPrev = windowStart > 0;
const canNext = windowStart + 3 < availableYears.length;
useEffect(() => {
if (!availableYears.length || initializedRef.current) return;
initializedRef.current = true;
const idx = availableYears.indexOf(annee);
const safe = idx >= 0 ? idx : availableYears.length - 1;
setWindowStart(Math.max(0, Math.min(availableYears.length - 3, safe - 1)));
}, [availableYears]);
/* ── Données affichées selon le mode ── */
const items = modeGlobal ? years : months;
const barCount = items.length;
/* ── SVG layout ── */
const W = 900, H = 280;
const PAD = { top: 52, right: 20, bottom: 34, left: 70 };
const plotW = W - PAD.left - PAD.right;
const plotH = H - PAD.top - PAD.bottom;
const gap = modeGlobal ? 10 : 8;
const barW = barCount > 0 ? Math.floor((plotW - gap * (barCount - 1)) / barCount) : 60;
const barBotY = PAD.top + plotH;
const filteredTotal = item =>
(showActual ? item.capitalAmt + item.cashbackAmt + item.interetsAmt : 0) +
(showProjected ? item.capitalProjAmt + item.interetsProjAmt : 0);
const filteredSum = items.reduce((s, item) => s + filteredTotal(item), 0);
const rawMax = Math.max(...items.map(item => filteredTotal(item)), 0.01);
const niceStep = (() => {
const raw = rawMax/4, mag = Math.pow(10,Math.floor(Math.log10(raw))), n = raw/mag;
return (n<1.5?1:n<3.5?2:n<7.5?5:10)*mag;
})();
const niceMax = Math.ceil(rawMax/niceStep)*niceStep || niceStep;
const yScale = v => PAD.top + plotH - (v/niceMax)*plotH;
const barX = i => PAD.left + i*(barW+gap);
const yTicks = Array.from({length: Math.round(niceMax/niceStep)+1}, (_,i) => ({v:i*niceStep, y:yScale(i*niceStep)}));
/* ── Tooltip ── */
const tooltipItem = hovered !== null ? items[hovered] : null;
const tooltipBCX = hovered !== null ? barX(hovered) + barW/2 : 0;
const tooltipBTY = hovered !== null ? yScale(filteredTotal(items[hovered])) : 0;
const anchorRight = tooltipBCX / W > 0.65;
const buildSegments = (item) => {
const actual = [];
if (showActual) {
if (inclureCapital && item.capitalAmt > 0) actual.push({color:chartCapital, v:item.capitalAmt});
if (inclureCashback && item.cashbackAmt > 0) actual.push({color:chartCashback, v:item.cashbackAmt});
if (inclureInterets && item.interetsAmt > 0) actual.push({color:chartInterets, v:item.interetsAmt});
}
const projected = [];
if (showProjected) {
if (inclureCapital && item.capitalProjAmt > 0) projected.push({color:chartCapital, v:item.capitalProjAmt});
if (inclureInterets && item.interetsProjAmt > 0) projected.push({color:chartInterets, v:item.interetsProjAmt});
}
return { actual, projected };
};
const activeTypes = [
inclureCapital && {key:'capital', color:chartCapital, label:'Capital'},
inclureCashback && {key:'cashback', color:chartCashback, label:'Cashback'},
inclureInterets && {key:'interets', color:chartInterets, label:netMode?'Intérêts nets':'Intérêts bruts'},
].filter(Boolean);
const activeTypeCount = [inclureInterets, inclureCapital, inclureCashback].filter(Boolean).length;
const AppIcon = ({ name, size = 28, active = false }) => {
const filename = libIcons[name];
if (filename) return (
<img src={ICONS_BASE + filename} className="app-lib-icon" width={size} height={size}
aria-hidden="true"
style={{ opacity: active ? 1 : 0.35, display: 'block', transition: 'opacity .15s' }} />
);
return <span style={{ width: size, height: size, display: 'block',
borderRadius: 4, background: 'var(--text-muted)',
opacity: active ? 0.55 : 0.2, transition: 'opacity .15s' }} />;
};
/* ── Label en-tête ── */
return (
<div className="solde-chart-wrap" style={{padding:'24px 24px 16px', height:'100%', boxSizing:'border-box'}}>
{/* ── En-tête ── */}
<div className="solde-chart-header">
<div className="solde-chart-info">
<div style={{display:'flex',alignItems:'center',gap:5,flexWrap:'wrap',marginBottom:2}}>
{inclureInterets && (
<span style={{display:'inline-flex',alignItems:'center',gap:4,background:hexToRgba(chartInterets,0.12),borderRadius:5,padding:'3px 8px'}}>
<span style={{width:7,height:7,borderRadius:2,background:chartInterets,flexShrink:0}}/>
<span style={{fontSize:13,color:chartInterets,fontWeight:600}}>{netMode?'Intérêts nets':'Intérêts bruts'}</span>
</span>
)}
{inclureCapital && (
<span style={{display:'inline-flex',alignItems:'center',gap:4,background:hexToRgba(chartCapital,0.12),borderRadius:5,padding:'3px 8px'}}>
<span style={{width:7,height:7,borderRadius:2,background:chartCapital,flexShrink:0}}/>
<span style={{fontSize:13,color:chartCapital,fontWeight:600}}>Capital</span>
</span>
)}
{inclureCashback && (
<span style={{display:'inline-flex',alignItems:'center',gap:4,background:hexToRgba(chartCashback,0.12),borderRadius:5,padding:'3px 8px'}}>
<span style={{width:7,height:7,borderRadius:2,background:chartCashback,flexShrink:0}}/>
<span style={{fontSize:13,color:chartCashback,fontWeight:600}}>Cashback</span>
</span>
)}
{!inclureInterets && !inclureCapital && !inclureCashback && (
<span style={{fontSize:13,color:'var(--text-muted)'}}></span>
)}
<span style={{fontSize:13,color:'var(--text-muted)'}}>· {modeGlobal?'Toutes les années':annee}</span>
</div>
<div className="solde-chart-value">{fmtTotal(filteredSum)}</div>
</div>
{isMobile ? (
/* ── Mobile : bouton Filtres ── */
<button
onClick={()=>setSheetOpen(true)}
style={{
display:'flex', alignItems:'center', gap:7,
background:'var(--surface-2)', border:'1px solid var(--border)',
borderRadius:20, padding:'6px 14px', cursor:'pointer',
fontSize:'var(--fs-sm)', fontWeight:600, color:'var(--text)',
}}>
Filtres
{activeTypeCount > 0 && (
<span style={{
background:'var(--primary)', color:'#fff',
borderRadius:10, padding:'1px 7px',
fontSize:11, fontWeight:700,
}}>{activeTypeCount}</span>
)}
</button>
) : (
/* ── Desktop : controls existants ── */
<div className="solde-chart-controls">
<button
title={inclureInterets?'Intérêts inclus — cliquer pour exclure':'Cliquer pour inclure les intérêts'}
onClick={()=>setInclureInterets(v=>!v)}
style={{background:inclureInterets?hexToRgba(chartInterets,0.13):'none', border:'1px solid '+(inclureInterets?chartInterets:'transparent'), borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center', transition:'background .15s,border-color .15s', marginRight:2}}>
<AppIcon name="interets" active={inclureInterets} />
</button>
<button
title={inclureCapital?'Capital inclus — cliquer pour exclure':'Cliquer pour inclure le capital'}
onClick={()=>setInclureCapital(v=>!v)}
style={{background:inclureCapital?hexToRgba(chartCapital,0.13):'none', border:'1px solid '+(inclureCapital?chartCapital:'transparent'), borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center', transition:'background .15s,border-color .15s', marginRight:2}}>
<AppIcon name="capital" active={inclureCapital} />
</button>
<button
title={inclureCashback?'Cashback inclus — cliquer pour exclure':'Cliquer pour inclure le cashback'}
onClick={()=>setInclureCashback(v=>!v)}
style={{background:inclureCashback?hexToRgba(chartCashback,0.13):'none', border:'1px solid '+(inclureCashback?chartCashback:'transparent'), borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center', transition:'background .15s,border-color .15s', marginRight:2}}>
<AppIcon name="cashback" active={inclureCashback} />
</button>
<div className="solde-chart-ranges">
{!modeGlobal && <>
<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${modeGlobal?' active':''}`} onClick={()=>toggleModeGlobal()}>
TOUT
</button>
</div>
</div>
)}
</div>
{/* ── SVG bar chart ── */}
<div style={{position:'relative', userSelect:'none'}}>
<svg
viewBox={`0 0 ${W} ${H}`}
style={{width:'100%', height:'auto', display:'block'}}
onMouseLeave={()=>setHovered(null)}
>
{yTicks.map(({v,y},i)=>(
<g key={i}>
<line x1={PAD.left} y1={y} x2={W-PAD.right} y2={y} stroke={GRID} strokeWidth="1" strokeDasharray={i===0?'':'3 5'}/>
<text x={PAD.left-10} y={y+4} textAnchor="end" fill={LABEL} fontSize="11" fontFamily="system-ui,sans-serif">{v===0?'':fmtShort(v)}</text>
</g>
))}
{items.map((item,i)=>{
const x=barX(i), isHov=hovered===i;
// Sélection : mois en mode mensuel, année en mode global
const isSel = modeGlobal ? (selectedYear===item.y) : (selectedMonth===i);
const dimmed = modeGlobal
? (selectedYear!==null && !isSel)
: (selectedMonth!==null && !isSel);
const {actual:aSegs,projected:pSegs}=buildSegments(item);
let cumH=0;
const aRects=aSegs.map(s=>{const h=(s.v/niceMax)*plotH; const r={color:s.color,x,y:barBotY-cumH-h,w:barW,h,isP:false}; cumH+=h; return r;});
const pRects=pSegs.map(s=>{const h=(s.v/niceMax)*plotH; const r={color:s.color,x,y:barBotY-cumH-h,w:barW,h,isP:true}; cumH+=h; return r;});
const all=[...aRects,...pRects];
const totalH=(filteredTotal(item)/niceMax)*plotH;
const topColor=all.length>0?all[all.length-1].color:LABEL;
const isCurrentMark = modeGlobal ? item.isCurrent : item.isCurrentMonth;
return (
<g key={i}
onMouseEnter={()=>setHovered(i)}
onClick={()=>{
if (modeGlobal) {
setSelectedYear(prev => prev===item.y ? null : item.y);
} else {
setSelectedMonth(prev => prev===i ? null : i);
}
}}
style={{cursor:'pointer', opacity:dimmed?0.35:1, transition:'opacity .15s'}}
>
{all.map((r,ri)=>(
<rect key={ri} x={r.x} y={r.y} width={r.w} height={r.h}
fill={r.color}
fillOpacity={r.isP?(isHov||isSel?0.48:0.28):(isHov||isSel?1.0:0.82)}
rx={ri===all.length-1?2:0} ry={ri===all.length-1?2:0}
/>
))}
{filteredTotal(item)>0&&(
<text x={x+barW/2} y={barBotY-totalH-5} textAnchor="middle"
fill={isHov||isSel?topColor:LABEL} fontSize="9" fontFamily="system-ui,sans-serif"
fontWeight={isHov||isSel?'700':undefined}>
{fmtShort(filteredTotal(item))}
</text>
)}
<text x={x+barW/2} y={H-10} textAnchor="middle"
fill={isCurrentMark||isSel?topColor:LABEL}
fontSize="10" fontFamily="system-ui,sans-serif"
fontWeight={isCurrentMark||isSel?'700':undefined}>
{item.label}
</text>
{isSel&&<rect x={x+barW/2-7} y={H-3} width={14} height={3} rx={1.5} fill={topColor} fillOpacity={0.9}/>}
</g>
);
})}
</svg>
{/* Tooltip */}
{tooltipItem&&tooltipItem.total>0&&(
<div style={{position:'absolute', ...(anchorRight?{right:`calc(${(1-tooltipBCX/W)*100}% + 8px)`}:{left:`calc(${(tooltipBCX/W)*100}% + 8px)`}), top:`calc(${(tooltipBTY/H)*100}% - 8px)`, transform:'translateY(-100%)', pointerEvents:'none', zIndex:20}}>
<div className="sg-tooltip">
<span className="sg-tooltip-date">
{modeGlobal
? `${tooltipItem.label}${tooltipItem.isCurrent?' · année en cours':''}`
: `${MOIS[tooltipItem.m-1]} ${annee}${tooltipItem.isCurrentMonth?' · mois en cours':''}`
}
</span>
{(()=>{
const ROW=({label,value,color=null,indent=false,muted=false})=>(
<span style={{fontSize:'var(--fs-xs)',color:'var(--text-muted)',display:'flex',justifyContent:'space-between',gap:16,alignItems:'center'}}>
{color&&<span style={{display:'inline-block',width:8,height:8,borderRadius:2,background:color,flexShrink:0}}/>}
<span style={{paddingLeft:(!color&&indent)?8:0,flex:1}}>{label}</span>
<span style={{color:muted?undefined:'var(--text)',fontWeight:muted?undefined:600}}>{value}</span>
</span>
);
const hasMix=tooltipItem.actual>0&&tooltipItem.projected>0;
const multi=[inclureCapital,inclureCashback,inclureInterets].filter(Boolean).length>1;
return(<>
{tooltipItem.actual>0&&<>
<ROW label="Reçu" value={fmtTotal(tooltipItem.actual)}/>
{multi&&inclureCapital &&tooltipItem.capitalAmt >0&&<ROW label="Capital" value={fmtTotal(tooltipItem.capitalAmt)} color={chartCapital} indent muted/>}
{multi&&inclureCashback&&tooltipItem.cashbackAmt>0&&<ROW label="Cashback" value={fmtTotal(tooltipItem.cashbackAmt)} color={chartCashback} indent muted/>}
{multi&&inclureInterets&&tooltipItem.interetsAmt>0&&<ROW label={`Intérêts (${netMode?'nets':'bruts'})`} value={fmtTotal(tooltipItem.interetsAmt)} color={chartInterets} indent muted/>}
{!multi&&inclureInterets&&<ROW label={`dont intérêts (${netMode?'nets':'bruts'})`} value={fmtTotal(tooltipItem.interetsAmt)} indent muted/>}
{!multi&&inclureCapital &&<ROW label="dont capital" value={fmtTotal(tooltipItem.capitalAmt)} indent muted/>}
{!multi&&inclureCashback&&<ROW label="dont cashback" value={fmtTotal(tooltipItem.cashbackAmt)} indent muted/>}
</>}
{tooltipItem.projected>0&&<>
<ROW label="Projeté" value={fmtTotal(tooltipItem.projected)}/>
{multi&&inclureCapital &&tooltipItem.capitalProjAmt >0&&<ROW label="Capital" value={fmtTotal(tooltipItem.capitalProjAmt)} color={chartCapital} indent muted/>}
{multi&&inclureInterets&&tooltipItem.interetsProjAmt>0&&<ROW label={`Intérêts (${netMode?'nets est.':'bruts'})`} value={fmtTotal(tooltipItem.interetsProjAmt)} color={chartInterets} indent muted/>}
{!multi&&inclureInterets&&<ROW label={`dont intérêts (${netMode?'nets est.':'bruts'})`} value={fmtTotal(tooltipItem.interetsProjAmt)} indent muted/>}
{!multi&&inclureCapital&&tooltipItem.capitalProjAmt>0&&<ROW label="dont capital" value={fmtTotal(tooltipItem.capitalProjAmt)} indent muted/>}
</>}
{hasMix&&<span className="sg-tooltip-value" style={{borderTop:'1px solid var(--border)',paddingTop:4,marginTop:2,display:'flex',justifyContent:'space-between'}}><span>Total</span><span>{fmtTotal(tooltipItem.total)}</span></span>}
{!hasMix&&<span className="sg-tooltip-value" style={{display:'flex',justifyContent:'space-between'}}><span>Total</span><span>{fmtTotal(tooltipItem.total)}</span></span>}
</>);
})()}
</div>
</div>
)}
</div>
{/* ── Légende + sélecteur Reçu/Projeté ── */}
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginTop:8,gap:12,flexWrap:'wrap'}}>
{/* Sélecteur Reçu/Projeté — desktop uniquement (mobile → bottom sheet) */}
{!isMobile && (
<div style={{
display:'inline-flex',
background:'#f0f0f0',
borderRadius:8,
padding:3,
gap:2,
flexShrink:0,
}}>
{[
{key:'actual', label:'Reçu', active:showActual, toggle:toggleActual},
{key:'projected', label:'Projeté', active:showProjected, toggle:toggleProjected},
].map(btn=>(
<button key={btn.key} onClick={()=>btn.toggle()} style={{
border:'none', cursor:'pointer', padding:'5px 14px', borderRadius:6,
fontSize:'var(--fs-sm)',
fontWeight: btn.active ? 600 : 400,
background: btn.active ? '#ffffff' : 'transparent',
color: btn.active ? '#1a1a2e' : '#9ca3af',
boxShadow: btn.active ? '0 1px 3px rgba(0,0,0,0.13)' : 'none',
transition: 'all .15s', lineHeight: 1.4,
}}>{btn.label}</button>
))}
</div>
)}
{/* Légende couleurs */}
<div style={{display:'flex',gap:12,alignItems:'center',flexWrap:'wrap',flex:1,justifyContent:'flex-end'}}>
{activeTypes.map(t=>(
<div key={t.key} style={{display:'flex',gap:8,alignItems:'center'}}>
{showActual&&(
<span style={{display:'flex',gap:4,alignItems:'center',fontSize:'var(--fs-xs)',color:'var(--text-muted)'}}>
<span style={{width:10,height:10,borderRadius:2,background:t.color,opacity:0.82,flexShrink:0}}/>
{t.label}{t.key==='interets'?' reçus':' reçu'}
</span>
)}
{showProjected&&(
<span style={{display:'flex',gap:4,alignItems:'center',fontSize:'var(--fs-xs)',color:'var(--text-muted)'}}>
<span style={{width:10,height:10,borderRadius:2,background:t.color,opacity:0.28,flexShrink:0}}/>
{t.label}{t.key==='interets'?' projetés':' projeté'}
</span>
)}
</div>
))}
</div>
</div>
{/* ── Bottom sheet mobile ── */}
{isMobile && (
<>
{/* Overlay */}
<div
onClick={()=>setSheetOpen(false)}
style={{
position:'fixed', inset:0, zIndex:200,
background:'rgba(0,0,0,0.45)',
opacity: sheetOpen ? 1 : 0,
pointerEvents: sheetOpen ? 'auto' : 'none',
transition:'opacity .25s',
}}
/>
{/* Sheet */}
<div style={{
position:'fixed', bottom:0, left:0, right:0, zIndex:201,
background:'var(--surface)',
borderRadius:'20px 20px 0 0',
borderTop:'1px solid var(--border)',
padding:'12px 20px 32px',
transform: sheetOpen ? 'translateY(0)' : 'translateY(100%)',
transition:'transform .3s cubic-bezier(.32,.72,0,1)',
maxHeight:'85vh', overflowY:'auto',
}}>
{/* Handle */}
<div style={{width:36,height:4,background:'var(--border)',borderRadius:2,margin:'0 auto 20px'}}/>
{/* Types */}
<div style={{fontSize:'var(--fs-xs)',color:'var(--text-muted)',fontWeight:600,textTransform:'uppercase',letterSpacing:'0.06em',marginBottom:12}}>
Types
</div>
{[
{key:'interets', label:netMode?'Intérêts nets':'Intérêts bruts', color:chartInterets, active:inclureInterets, set:setInclureInterets},
{key:'capital', label:'Capital', color:chartCapital, active:inclureCapital, set:setInclureCapital},
{key:'cashback', label:'Cashback', color:chartCashback, active:inclureCashback, set:setInclureCashback},
].map((t,i,arr)=>(
<div key={t.key} onClick={()=>t.set(v=>!v)} style={{
display:'flex', alignItems:'center', justifyContent:'space-between',
padding:'12px 0',
borderBottom: i<arr.length-1 ? '1px solid var(--border)' : 'none',
cursor:'pointer',
}}>
<div style={{display:'flex',alignItems:'center',gap:10}}>
<span style={{width:10,height:10,borderRadius:2,background:t.color,flexShrink:0}}/>
<span style={{fontSize:'var(--fs-sm)',color:'var(--text)',fontWeight:500}}>{t.label}</span>
</div>
{/* Toggle */}
<div style={{
width:44, height:24, borderRadius:12, flexShrink:0,
background: t.active ? t.color : 'var(--border)',
position:'relative', transition:'background .2s',
}}>
<div style={{
width:20, height:20, borderRadius:10, background:'#fff',
position:'absolute', top:2,
left: t.active ? 22 : 2,
transition:'left .2s',
boxShadow:'0 1px 3px rgba(0,0,0,0.2)',
}}/>
</div>
</div>
))}
{/* Période */}
<div style={{fontSize:'var(--fs-xs)',color:'var(--text-muted)',fontWeight:600,textTransform:'uppercase',letterSpacing:'0.06em',margin:'20px 0 12px'}}>
Période
</div>
<div style={{display:'flex',gap:8,flexWrap:'wrap',alignItems:'center'}}>
{availableYears.map(y=>(
<button key={y}
onClick={()=>{ setAnnee(y); if(modeGlobal) toggleModeGlobal(); }}
style={{
padding:'7px 16px', borderRadius:8, fontSize:'var(--fs-sm)',
fontWeight: !modeGlobal && annee===y ? 700 : 500,
background: !modeGlobal && annee===y ? 'var(--primary)' : 'var(--surface-2)',
color: !modeGlobal && annee===y ? '#fff' : 'var(--text)',
border: !modeGlobal && annee===y ? '1px solid var(--primary)' : '1px solid var(--border)',
cursor:'pointer',
}}>
{y}
</button>
))}
<button
onClick={()=>{ if(!modeGlobal) toggleModeGlobal(); }}
style={{
padding:'7px 16px', borderRadius:8, fontSize:'var(--fs-sm)',
fontWeight: modeGlobal ? 700 : 500,
background: modeGlobal ? 'var(--primary)' : 'var(--surface-2)',
color: modeGlobal ? '#fff' : 'var(--text)',
border: modeGlobal ? '1px solid var(--primary)' : '1px solid var(--border)',
cursor:'pointer',
}}>
Tout
</button>
</div>
{/* Affichage */}
<div style={{fontSize:'var(--fs-xs)',color:'var(--text-muted)',fontWeight:600,textTransform:'uppercase',letterSpacing:'0.06em',margin:'20px 0 12px'}}>
Affichage
</div>
{[
{key:'actual', label:'Reçu', active:showActual, toggle:toggleActual},
{key:'projected', label:'Projeté', active:showProjected, toggle:toggleProjected},
].map((btn,i,arr)=>(
<div key={btn.key} onClick={()=>btn.toggle()} style={{
display:'flex', alignItems:'center', justifyContent:'space-between',
padding:'12px 0',
borderBottom: i<arr.length-1 ? '1px solid var(--border)' : 'none',
cursor:'pointer',
}}>
<span style={{fontSize:'var(--fs-sm)',color:'var(--text)',fontWeight:500}}>{btn.label}</span>
<div style={{
width:44, height:24, borderRadius:12, flexShrink:0,
background: btn.active ? 'var(--primary)' : 'var(--border)',
position:'relative', transition:'background .2s',
}}>
<div style={{
width:20, height:20, borderRadius:10, background:'#fff',
position:'absolute', top:2,
left: btn.active ? 22 : 2,
transition:'left .2s',
boxShadow:'0 1px 3px rgba(0,0,0,0.2)',
}}/>
</div>
</div>
))}
</div>
</>
)}
</div>
);
}
+312
View File
@@ -0,0 +1,312 @@
import { useMemo, useState, useRef } from 'react';
/* ── Constantes ─────────────────────────────────────────────── */
const BLUE = '#4fa8e8';
const BG = '#070c15';
const GRID = 'rgba(255,255,255,0.055)';
const LABEL = '#4a5568';
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 }) + ' €';
}
/* ── Composant ──────────────────────────────────────────────── */
export default function InvChart({ rows, remboursements, reinvestissements, platYear }) {
const [range, setRange] = useState('TOUT');
const [hover, setHover] = useState(null);
const svgRef = useRef(null);
const wrapRef = useRef(null);
/* ── Date de coupure : 31/12/{platYear} pour les années passées, aujourd'hui pour l'année en cours ou sans filtre ── */
const todayStr = new Date().toISOString().slice(0, 10);
const currentYear = String(new Date().getFullYear());
const cutoffStr = platYear && platYear !== currentYear ? `${platYear}-12-31` : todayStr;
/* ── 1. Courbe capital en cours = investissements capital remboursé ── */
const allPoints = useMemo(() => {
if (!rows?.length) return [];
// Tous les investissements du scope (le capRestant sera 0 pour les remboursés)
const rowIds = new Set(rows.map(r => r.id));
// Variations de capital par date
const deltas = {};
// +montant_investi à chaque date de souscription
for (const r of rows) {
const d = r.date_souscription?.slice(0, 10);
if (!d || d > cutoffStr) continue;
deltas[d] = (deltas[d] || 0) + (r.montant_investi ?? 0);
}
// +réinvestissements à leur date propre (dans le scope, avant coupure)
if (reinvestissements?.length) {
for (const rv of reinvestissements) {
if (!rowIds.has(rv.investissement_id)) continue;
const d = rv.date_reinvestissement?.slice(0, 10);
if (!d || !rv.montant || d > cutoffStr) continue;
deltas[d] = (deltas[d] || 0) + rv.montant;
}
}
// -capital à chaque date de remboursement (investissements du scope, avant coupure)
if (remboursements?.length) {
for (const rb of remboursements) {
if (!rowIds.has(rb.investissement_id)) continue;
const d = rb.date_remb?.slice(0, 10);
if (!d || !rb.capital || d > cutoffStr) continue;
deltas[d] = (deltas[d] || 0) - rb.capital;
}
}
// Tri chronologique + cumul
let cum = 0;
return Object.keys(deltas).sort().map(date => {
cum += deltas[date];
return { date, value: Math.max(0, cum) };
});
}, [rows, remboursements, reinvestissements, cutoffStr]);
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">Capital investi · {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="inv-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="inv-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="rgba(255,255,255,0.18)" strokeWidth="1" />
)}
{/* Aire dégradée + courbe */}
<path d={areaPath} fill="url(#inv-fill)" />
<path d={linePath} fill="none" stroke={BLUE} strokeWidth="1.5"
filter="url(#inv-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="rgba(255,255,255,0.12)" 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>
);
}
+206
View File
@@ -0,0 +1,206 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { fmtEUR, fmtStatut } 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')}`;
}
function startOfMonth(Y, M) {
return `${Y}-${String(M).padStart(2,'0')}-01`;
}
const STATUT_BG = {
en_cours: 'var(--b-en_cours-bg)',
rembourse: 'var(--b-rembourse-bg)',
en_retard: 'var(--b-en_retard-bg)',
procedure: 'var(--b-procedure-bg)',
cloture: 'var(--surface-2)',
};
const STATUT_FG = {
en_cours: 'var(--b-en_cours-fg)',
rembourse: 'var(--b-rembourse-fg)',
en_retard: 'var(--b-en_retard-fg)',
procedure: 'var(--b-procedure-fg)',
cloture: 'var(--text-muted)',
};
export default function InvMensuelTable({ rows, allRembs, allReinvests, year }) {
const navigate = useNavigate();
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
const displayYear = year ? Number(year) : currentYear;
/* ── Precompute rembs ── */
const reinvestByInv = useMemo(() => {
const map = {};
for (const rv of (allReinvests || [])) {
const id = rv.investissement_id;
if (!id) continue;
if (!map[id]) map[id] = [];
map[id].push({ date: rv.date_reinvestissement?.slice(0,10), montant: rv.montant || 0 });
}
return map;
}, [allReinvests]);
const capRembByInv = useMemo(() => {
const map = {};
for (const rb of (allRembs || [])) {
const id = rb.investissement_id;
if (!id || rb.type !== 'normal') continue;
if (!map[id]) map[id] = [];
map[id].push({ date: rb.date_remb?.slice(0,10), capital: rb.capital || 0 });
}
return map;
}, [allRembs]);
const lastRembDateMap = useMemo(() => {
const map = {};
for (const rb of (allRembs || [])) {
const id = rb.investissement_id;
const d = rb.date_remb?.slice(0,10);
if (!id || !d) continue;
if (!map[id] || d > map[id]) map[id] = d;
}
return map;
}, [allRembs]);
/* ── Capital encours d'un investissement à fin de mois M ── */
const getCapital = (inv, Y, M) => {
const endM = endOfMonth(Y, M);
if (inv.date_souscription > endM) return 0;
const startM = startOfMonth(Y, M);
const ACTIVE = ['en_cours', 'en_retard', 'procedure'];
const isActive = ACTIVE.includes(inv.statut) ||
((inv.date_cible || lastRembDateMap[inv.id] || null) >= startM);
if (!isActive) return 0;
const reinvM = (reinvestByInv[inv.id] || [])
.filter(rv => rv.date && rv.date <= endM)
.reduce((s, rv) => s + rv.montant, 0);
const capRembM = (capRembByInv[inv.id] || [])
.filter(rb => rb.date && rb.date <= endM)
.reduce((s, rb) => s + rb.capital, 0);
return Math.max(0, inv.montant_investi + reinvM - capRembM);
};
/* ── Grille : une ligne par investissement ── */
const grid = useMemo(() => {
if (!rows?.length) return [];
return rows
.map(inv => ({
inv,
months: Array.from({ length: 12 }, (_, i) => getCapital(inv, displayYear, i + 1)),
}))
.filter(r => r.months.some(v => v > 0))
.sort((a, b) =>
(a.inv.date_souscription || '') < (b.inv.date_souscription || '') ? -1 : 1
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows, displayYear, reinvestByInv, capRembByInv, lastRembDateMap]);
const monthTotals = useMemo(() =>
Array.from({ length: 12 }, (_, i) => grid.reduce((s, r) => s + r.months[i], 0)),
[grid]
);
if (!grid.length) {
return (
<div style={{ padding: '24px', color: 'var(--text-muted)', fontSize: 'var(--fs-sm)', textAlign: 'center' }}>
Aucun investissement actif pour {displayYear}.
</div>
);
}
return (
<div style={{ overflowX: 'auto', position: 'relative', zIndex: 0 }}>
<table className="tip-table">
<thead>
<tr>
<th className="tip-th-empty" style={{ minWidth: 200 }} />
<th className="tip-th-empty" style={{ minWidth: 90 }} />
<th className="tip-th-year" colSpan={12}>{displayYear}</th>
</tr>
<tr>
<th className="tip-th-name tip-th-name-amber" style={{ minWidth: '22ch', maxWidth: '40ch' }}>Investissement</th>
<th style={{
padding: '7px 10px', background: 'var(--surface-2)', color: 'var(--text)',
fontWeight: 600, fontSize: 'var(--fs-xs)', textAlign: 'left',
borderRight: '1px solid var(--border)', whiteSpace: 'nowrap',
}}>Statut</th>
{MOIS_LONG.map((m, i) => (
<th key={m}
className={`tip-th-month${displayYear === currentYear && i === currentMonth - 1 ? ' tip-th-month-current' : ''}`}>
{m}
</th>
))}
</tr>
</thead>
<tbody>
{grid.map(({ inv, months }) => (
<tr key={inv.id} className="tip-row-plat"
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/investissements/${inv.id}`)}>
<td className="tip-td-name" style={{ whiteSpace: 'normal', maxWidth: '40ch', wordBreak: 'break-word' }}>
{inv.nom_projet || '—'}
</td>
<td style={{ padding: '8px 10px', whiteSpace: 'nowrap', borderRight: '1px solid var(--border)' }}>
<span style={{
display: 'inline-block', padding: '2px 8px', borderRadius: 4,
fontSize: 'var(--fs-xs)', fontWeight: 600,
background: STATUT_BG[inv.statut] || 'var(--surface-2)',
color: STATUT_FG[inv.statut] || 'var(--text-muted)',
}}>
{fmtStatut(inv.statut)}
</span>
</td>
{months.map((v, mi) => {
const curClass = displayYear === currentYear && mi === currentMonth - 1 ? ' tip-col-current' : '';
if (v === 0) {
// Avant la date de souscription
const subYear = Number(inv.date_souscription?.slice(0, 4));
const subMo = Number(inv.date_souscription?.slice(5, 7)) - 1;
const isBefore = inv.date_souscription && (
subYear > displayYear || (subYear === displayYear && mi < subMo)
);
// Après le dernier remboursement (prêt remboursé)
const lastDate = lastRembDateMap[inv.id];
const isAfter = inv.statut === 'rembourse' && lastDate && (() => {
const lastYear = Number(lastDate.slice(0, 4));
const lastMo = Number(lastDate.slice(5, 7)) - 1;
if (lastYear < displayYear) return true;
if (lastYear === displayYear) return mi > lastMo;
return false;
})();
if (isBefore || isAfter) {
return <td key={mi} className={`tip-td-closed${curClass}`} />;
}
}
return (
<td key={mi} className={`tip-td-num${curClass}`}>
{v > 0 ? fmtEUR(v) : <span className="tip-dash"></span>}
</td>
);
})}
</tr>
))}
</tbody>
<tfoot>
<tr className="tip-footer-total">
<td className="tip-td-name">Total</td>
<td />
{monthTotals.map((v, i) => (
<td key={i}
className={`tip-td-num${displayYear === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`}>
{v > 0 ? fmtEUR(v) : <span className="tip-dash"></span>}
</td>
))}
</tr>
</tfoot>
</table>
</div>
);
}
+195
View File
@@ -0,0 +1,195 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { api } from '../api.js';
/**
* InvSelect — multi-select with checkboxes + inline "Add item"
* Generic replacement for CategorySelect, works with categories_inv / secteurs_inv.
*
* Props:
* items : { id, nom, is_global }[] — liste complète fournie par le parent
* selected : number[] — ids sélectionnés
* onChange : (ids: number[]) => void
* addApiPath : string — ex. '/categories-inv' | '/secteurs-inv'
* onItemAdded : ({ id, nom, is_global }) => void — appelé après création inline
* emptyLabel : string — texte si rien de sélectionné
* addLabel : string — texte du bouton "Ajouter"
* inputPlaceholder : string — placeholder du champ de création
*/
export default function InvSelect({
items = [],
selected = [],
onChange,
addApiPath,
onItemAdded,
emptyLabel = 'Aucun élément sélectionné',
addLabel = 'Ajouter un élément',
inputPlaceholder = 'Nom…',
inheritedIds = [],
}) {
const [open, setOpen] = useState(false);
const [adding, setAdding] = useState(false);
const [newName, setNewName] = useState('');
const [err, setErr] = useState(null);
const [busy, setBusy] = useState(false);
const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 0 });
const wrapRef = useRef(null);
const triggerRef = useRef(null);
useLayoutEffect(() => {
if (!open || !triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
setDropPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
}, [open]);
useEffect(() => {
if (!open) return;
const close = (e) => {
if (wrapRef.current?.contains(e.target)) return;
const drop = document.getElementById('inv-select-dropdown-portal');
if (drop?.contains(e.target)) return;
setOpen(false);
};
const closeOnScroll = (e) => {
const drop = document.getElementById('inv-select-dropdown-portal');
if (drop?.contains(e.target)) return; // scroll dans le dropdown — on garde ouvert
setOpen(false);
};
document.addEventListener('mousedown', close);
window.addEventListener('scroll', closeOnScroll, true);
window.addEventListener('resize', closeOnScroll);
return () => {
document.removeEventListener('mousedown', close);
window.removeEventListener('scroll', closeOnScroll, true);
window.removeEventListener('resize', closeOnScroll);
};
}, [open]);
const toggle = (id) => {
if (inheritedIds.includes(id)) return; // tag hérité du référentiel, non modifiable
onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]);
};
const addItem = async (e) => {
e.preventDefault();
if (!newName.trim()) return;
setBusy(true); setErr(null);
try {
const item = await api.post(addApiPath, { nom: newName.trim() });
onItemAdded?.(item);
onChange([...selected, item.id]);
setNewName('');
setAdding(false);
} catch (e) {
setErr(e.message);
} finally {
setBusy(false);
}
};
const triggerLabel = (() => {
if (selected.length === 0) return emptyLabel;
const names = items.filter(c => selected.includes(c.id)).map(c => c.nom);
if (names.length <= 2) return names.join(', ');
return `${names.length} éléments sélectionnés`;
})();
const dropdown = open ? (
<div
id="inv-select-dropdown-portal"
className="cat-select-dropdown"
role="listbox"
aria-multiselectable="true"
style={{ position: 'fixed', top: dropPos.top, left: dropPos.left, width: dropPos.width, zIndex: 9999 }}
>
{items.length === 0 && (
<div className="cat-select-empty">{emptyLabel}</div>
)}
{items.map(item => {
const checked = selected.includes(item.id);
const inherited = inheritedIds.includes(item.id);
return (
<label key={item.id} className={`cat-select-item${checked ? ' checked' : ''}${inherited ? ' inherited' : ''}`}
title={inherited ? 'Hérité du référentiel — non modifiable' : undefined}>
<input type="checkbox" checked={checked || inherited} disabled={inherited} onChange={() => toggle(item.id)} />
<span>{item.nom}</span>
{inherited
? <span style={{ marginLeft: 'auto', fontSize: 10, fontWeight: 600, padding: '1px 5px',
borderRadius: 3, background: 'var(--accent)', color: '#fff', opacity: .85 }}>Réf</span>
: checked && (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ marginLeft: 'auto', color: 'var(--accent)' }} aria-hidden="true">
<polyline points="20 6 9 17 4 12"/>
</svg>
)
}
</label>
);
})}
{addApiPath && (
<>
<div className="cat-select-sep" />
{!adding ? (
<button type="button" className="cat-select-add-btn"
onClick={() => { setAdding(true); setErr(null); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
aria-hidden="true">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
{addLabel}
</button>
) : (
<form onSubmit={addItem} className="cat-select-new-form">
<input
autoFocus
value={newName}
onChange={e => setNewName(e.target.value)}
placeholder={inputPlaceholder}
maxLength={200}
/>
<div className="cat-select-new-actions">
<button type="submit" className="primary" disabled={busy || !newName.trim()}>
{busy ? '…' : 'Créer'}
</button>
<button type="button" className="ghost"
onClick={() => { setAdding(false); setNewName(''); setErr(null); }}>
Annuler
</button>
</div>
{err && <div className="cat-select-err">{err}</div>}
</form>
)}
</>
)}
</div>
) : null;
return (
<>
<div ref={wrapRef} className="cat-select-wrap">
<button
ref={triggerRef}
type="button"
className={`cat-select-trigger${open ? ' open' : ''}`}
onClick={() => { setOpen(o => !o); setAdding(false); setErr(null); }}
aria-haspopup="listbox"
aria-expanded={open}
>
<span className="cat-select-label">{triggerLabel}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ flexShrink: 0, transition: 'transform .15s', transform: open ? 'rotate(0deg)' : 'rotate(180deg)' }}
aria-hidden="true">
<path d="M18 15l-6-6-6 6"/>
</svg>
</button>
</div>
{dropdown}
</>
);
}
+341
View File
@@ -0,0 +1,341 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useUi } from '../context/UiContext.jsx';
import Logo from './Logo.jsx';
import UserMenu from './UserMenu.jsx';
/* ── Icônes nav ─────────────────────────────────────────────── */
const ICONS_BASE = '/api/icons-files/';
const I = ({ children }) => (
<svg width="16" height="16" viewBox="0 0 18 18" fill="none"
stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"
aria-hidden="true">
{children}
</svg>
);
const IconDashboard = () => <I><rect x="1" y="1" width="7" height="7" rx="1.5"/><rect x="10" y="1" width="7" height="7" rx="1.5"/><rect x="1" y="10" width="7" height="7" rx="1.5"/><rect x="10" y="10" width="7" height="7" rx="1.5"/></I>;
const IconDeposits = () => <I><line x1="6" y1="14" x2="6" y2="4"/><polyline points="3,7 6,4 9,7"/><line x1="12" y1="4" x2="12" y2="14"/><polyline points="9,11 12,14 15,11"/></I>;
const IconInvestments = () => <I><polyline points="1,15 5,9 9,11 15,3"/><polyline points="11,3 15,3 15,7"/></I>;
const IconRepayments = () => <I><path d="M15 9A6 6 0 1 1 9 3"/><polyline points="15,3 15,9 9,9"/></I>;
const IconFlatTax = () => <I><circle cx="9" cy="9" r="7.5"/><path d="M9 1.5a11 11 0 0 1 3.5 7.5 11 11 0 0 1-3.5 7.5 11 11 0 0 1-3.5-7.5 11 11 0 0 1 3.5-7.5z"/><line x1="1.5" y1="9" x2="16.5" y2="9"/></I>;
/* Icône nav hybride : bibliothèque si dispo, sinon fallback SVG inline */
function NavIcon({ libFilename, Fallback }) {
if (libFilename) {
return (
<img
src={`${ICONS_BASE}${libFilename}`}
width="24" height="24"
className="nav-lib-icon"
aria-hidden="true"
alt=""
/>
);
}
return <Fallback />;
}
/* Bouton « réduire » (visible dans la sidebar étendue) */
const IconPanelCollapse = () => (
<svg width="18" height="18" viewBox="0 0 20 18" fill="none"
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
aria-hidden="true">
<rect x=".75" y=".75" width="18.5" height="16.5" rx="2.5"/>
<line x1="7" y1=".75" x2="7" y2="17.25"/>
<path d="M13 5.5 L10 9 L13 12.5"/>
</svg>
);
/* Bouton « étendre » (apparaît au hover du logo en mode réduit) */
const IconPanelExpand = () => (
<svg width="18" height="18" viewBox="0 0 20 18" fill="none"
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
aria-hidden="true">
<rect x=".75" y=".75" width="18.5" height="16.5" rx="2.5"/>
<line x1="7" y1=".75" x2="7" y2="17.25"/>
<path d="M3 5.5 L6 9 L3 12.5"/>
</svg>
);
/* ── Recherche rapide de projet ─────────────────────────────── */
function ProjectSearch() {
const navigate = useNavigate();
const { activeId, activeView } = useInvestisseur();
const [query, setQuery] = useState('');
const [allInv, setAllInv] = useState([]);
const [open, setOpen] = useState(false);
const [activeIdx, setActiveIdx] = useState(-1);
const inputRef = useRef(null);
const wrapRef = useRef(null);
/* Chargement (ou rechargement) des investissements */
const loadInv = useCallback(async () => {
try {
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
const rows = await api.get('/investissements', scopeParams);
setAllInv(rows);
} catch {}
}, [activeView]);
useEffect(() => { loadInv(); }, [loadInv, activeId]);
/* Raccourci clavier global Ctrl+K / Cmd+K */
useEffect(() => {
const h = e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
inputRef.current?.focus();
inputRef.current?.select();
}
};
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, []);
/* Fermeture au clic extérieur */
useEffect(() => {
const h = e => { if (!wrapRef.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, []);
/* Résultats filtrés */
const results = (() => {
const q = query.trim().toLowerCase();
if (!q) return [];
return allInv
.filter(r => r.nom_projet?.toLowerCase().includes(q) || r.plateforme_nom?.toLowerCase().includes(q))
.slice(0, 8);
})();
/* Synchronise l'ouverture du dropdown */
useEffect(() => {
setOpen(results.length > 0 && query.trim().length > 0);
setActiveIdx(-1);
}, [results.length, query]); /* eslint-disable-line */
const goTo = (inv) => {
setQuery(''); setOpen(false);
navigate(`/investissements/${inv.id}`);
};
const handleKeyDown = (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIdx(i => Math.min(i + 1, results.length - 1)); }
if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIdx(i => Math.max(i - 1, -1)); }
if (e.key === 'Enter') { e.preventDefault(); if (activeIdx >= 0) goTo(results[activeIdx]); else if (results.length === 1) goTo(results[0]); }
if (e.key === 'Escape') { setOpen(false); setQuery(''); inputRef.current?.blur(); }
};
const STATUT_LABELS = {
en_cours: 'en cours',
rembourse: 'remboursé',
en_retard: 'en retard',
procedure: 'procédure',
cloture: 'clôturé',
};
const statutColor = (s) => {
if (s === 'en_cours') return 'var(--b-en_cours-fg)';
if (s === 'rembourse') return 'var(--b-rembourse-fg)';
if (s === 'en_retard') return 'var(--b-en_retard-fg)';
if (s === 'procedure') return 'var(--b-procedure-fg)';
return 'var(--text-muted)';
};
return (
<div ref={wrapRef} style={{ position: 'relative' }}>
<div className="project-search-wrap">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
style={{ opacity: 0.7, flexShrink: 0 }}>
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
ref={inputRef}
className="project-search-input"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Rechercher un projet…"
autoComplete="off"
spellCheck="false"
/>
{query ? (
<button className="project-search-clear"
onMouseDown={e => { e.preventDefault(); setQuery(''); setOpen(false); inputRef.current?.focus(); }}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.8" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
) : (
<kbd className="project-search-kbd">K</kbd>
)}
</div>
{open && (
<div className="project-search-dropdown">
{results.map((inv, i) => (
<div key={inv.id}
className={`project-search-item${activeIdx === i ? ' active' : ''}`}
onMouseDown={() => goTo(inv)}
onMouseEnter={() => setActiveIdx(i)}
>
<div className="project-search-item-name">{inv.nom_projet}</div>
<div className="project-search-item-meta">
<span>{inv.plateforme_nom}</span>
<span>·</span>
<span style={{ color: statutColor(inv.statut) }}>{STATUT_LABELS[inv.statut] ?? inv.statut?.replace('_', ' ')}</span>
{inv.montant_investi != null && (
<>
<span>·</span>
<span>{inv.montant_investi.toLocaleString('fr-FR')} </span>
</>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
/* ── Layout ─────────────────────────────────────────────────── */
function IconPlusCircle() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="16"/>
<line x1="8" y1="12" x2="16" y2="12"/>
</svg>
);
}
export default function Layout() {
const { sidebarCollapsed, toggleSidebar, displayMode, setDisplayMode } = useUi();
const navigate = useNavigate();
const [navIcons, setNavIcons] = useState({});
useEffect(() => {
api.get('/icons').then(rows => {
const m = {};
rows.forEach(r => { m[r.name] = r.filename; });
setNavIcons(m);
}).catch(() => {});
}, []);
return (
<div className={`app-shell${sidebarCollapsed ? ' sidebar-collapsed' : ''}`}>
<aside className="sidebar">
{/* ── Header ─────────────────────────────────────────── */}
<div className="sidebar-brand">
<div className="sidebar-brand-logo">
<Logo size={34} />
</div>
<span className="sidebar-brand-text">Crowdlending</span>
{/* Réduire (visible seulement en mode étendu) */}
<button
className="sidebar-panel-btn"
onClick={toggleSidebar}
title="Réduire le menu"
aria-label="Réduire le menu"
>
<IconPanelCollapse />
</button>
{/* Étendre — overlay au hover du logo en mode réduit */}
<button
className="sidebar-expand-overlay"
onClick={toggleSidebar}
title="Ouvrir le menu"
aria-label="Ouvrir le menu"
>
<IconPanelExpand />
</button>
</div>
{/* ── Navigation ─────────────────────────────────────── */}
<nav className="sidebar-nav">
<NavLink to="/" end title="Tableau de bord">
<NavIcon libFilename={navIcons.dashboard} Fallback={IconDashboard} /><span className="nav-label">Tableau de bord</span>
</NavLink>
<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>
<NavLink to="/taxreport" title="Fiscalité">
<NavIcon libFilename={navIcons.tax} Fallback={IconFlatTax} /><span className="nav-label">Fiscalité</span>
</NavLink>
</nav>
{/* ── Pied : menu utilisateur ─────────────────────────── */}
<UserMenu />
</aside>
<main className="main">
<div className="topbar topbar-global">
<ProjectSearch />
<div className="topbar-right">
<button
className="btn-add-invest"
onClick={() => navigate('/investissements?new=1')}
>
<IconPlusCircle />
Ajout Investissement
</button>
<button
className="btn-add-invest"
onClick={() => navigate('/remboursements?new=1')}
>
<IconPlusCircle />
Nouveau remboursement
</button>
<button
className="btn-add-invest"
onClick={() => navigate('/depots-retraits?new=1')}
>
<IconPlusCircle />
Nouveau dépôt/retrait
</button>
<div className="display-toggle" role="group" aria-label="Mode d'affichage">
<button
className={`display-toggle-btn${displayMode === 'brut' ? ' active' : ''}`}
onClick={() => setDisplayMode('brut')}
aria-pressed={displayMode === 'brut'}
>
Brut
</button>
<button
className={`display-toggle-btn${displayMode === 'net' ? ' active' : ''}`}
onClick={() => setDisplayMode('net')}
aria-pressed={displayMode === 'net'}
>
Net
</button>
</div>
</div>
</div>
<Outlet />
</main>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
export default function Logo({ size = 32 }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
width={size}
height={size}
style={{ display: 'block', flexShrink: 0 }}
aria-hidden="true"
>
<rect width="48" height="48" rx="10" fill="#1e3a8a" />
<rect x="7" y="31" width="9" height="11" rx="2" fill="#93c5fd" />
<rect x="20" y="21" width="9" height="21" rx="2" fill="#60a5fa" />
<rect x="33" y="11" width="9" height="31" rx="2" fill="white" />
<polygon points="37.5,4 44,12 31,12" fill="#4ade80" />
</svg>
);
}
+19
View File
@@ -0,0 +1,19 @@
export default function Modal({ open, title, onClose, children, footer, width = 600 }) {
if (!open) return null;
return (
<div className="modal-backdrop">
<div
className="card"
style={{ width: '100%', maxWidth: width, maxHeight: '90vh', overflow: 'auto' }}
onClick={(e) => e.stopPropagation()}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h3 style={{ margin: 0 }}>{title}</h3>
<button className="ghost" onClick={onClose}></button>
</div>
{children}
{footer && <div style={{ marginTop: 16, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>{footer}</div>}
</div>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react';
import { api } from '../api.js';
const ICONS_BASE = '/api/icons-files/';
let _cache = null;
let _promise = null;
function getIcons() {
if (_cache) return Promise.resolve(_cache);
if (!_promise) {
_promise = api.get('/icons')
.then(rows => { _cache = {}; rows.forEach(r => { _cache[r.name] = r.filename; }); return _cache; })
.catch(() => { _cache = {}; return _cache; });
}
return _promise;
}
export default function PageIcon({ name, size = 40 }) {
const [filename, setFilename] = useState(() => _cache?.[name] ?? null);
useEffect(() => {
if (_cache) { setFilename(_cache[name] ?? null); return; }
getIcons().then(m => setFilename(m[name] ?? null));
}, [name]);
if (!filename) return null;
return (
<img
src={`${ICONS_BASE}${filename}`}
width={size}
height={size}
alt=""
aria-hidden="true"
style={{
display: 'inline',
verticalAlign: 'middle',
marginRight: 10,
objectFit: 'contain',
opacity: 0.85,
}}
/>
);
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Barre de pagination réutilisable.
* Props : page, setPage, pageSize, setPageSize, totalPages, totalItems, PAGE_SIZES
*/
export default function Pagination({ page, setPage, pageSize, setPageSize, totalPages, totalItems, PAGE_SIZES }) {
if (totalItems === 0) return null;
const start = (page - 1) * pageSize + 1;
const end = Math.min(page * pageSize, totalItems);
return (
<div className="pagination-bar">
<span className="pagination-info">
{start}{end} sur {totalItems}
</span>
<div className="pagination-controls">
<label className="pagination-size-label">
Lignes&nbsp;:&nbsp;
<select
className="pagination-size-select"
value={pageSize}
onChange={e => setPageSize(Number(e.target.value))}
>
{PAGE_SIZES.map(n => (
<option key={n} value={n}>{n}</option>
))}
</select>
</label>
<button
className="pagination-btn"
onClick={() => setPage(1)}
disabled={page === 1}
title="Première page"
>«</button>
<button
className="pagination-btn"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
title="Page précédente"
></button>
<span className="pagination-pages">{page} / {totalPages}</span>
<button
className="pagination-btn"
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
title="Page suivante"
></button>
<button
className="pagination-btn"
onClick={() => setPage(totalPages)}
disabled={page === totalPages}
title="Dernière page"
>»</button>
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { useEffect } from 'react';
/**
* ResultBanner — bannière succès/erreur avec auto-dismiss et × à droite.
*
* Props:
* result : { ok: bool, msg: string } | null
* onDismiss : () => void — appelé à la fermeture (manuelle ou auto)
* delay : number — délai auto-dismiss en ms (défaut 4000)
*/
export default function ResultBanner({ result, onDismiss, delay = 4000, style = {} }) {
useEffect(() => {
if (!result) return;
const t = setTimeout(onDismiss, delay);
return () => clearTimeout(t);
}, [result, delay, onDismiss]);
if (!result) return null;
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 14px',
borderRadius: 8,
fontSize: 13,
background: result.ok ? 'rgba(34,197,94,.1)' : 'rgba(239,68,68,.1)',
color: result.ok ? '#16a34a' : '#dc2626',
border: `1px solid ${result.ok ? 'rgba(34,197,94,.3)' : 'rgba(239,68,68,.3)'}`,
...style,
}}>
<span>{result.msg}</span>
<button
onClick={onDismiss}
style={{
marginLeft: 16,
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'inherit',
fontSize: 18,
lineHeight: 1,
padding: '0 2px',
flexShrink: 0,
}}
aria-label="Fermer"
>×</button>
</div>
);
}
+297
View File
@@ -0,0 +1,297 @@
import { useMemo, useState, useRef, useEffect } from 'react';
/* ── Constantes ─────────────────────────────────────────────── */
const GOLD = '#4fa8e8'; // bleu ciel — accord avec le thème navy du site
const BG = '#070c15';
const GRID = 'rgba(255,255,255,0.055)';
const LABEL = '#4a5568';
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.'];
/* ── 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') return `${day} ${mon}`;
if (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 }) + ' €';
}
function fmtTodayFull() {
return new Date().toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' });
}
/* ── Composant ──────────────────────────────────────────────── */
export default function SoldeChart({ rows }) {
const [range, setRange] = useState('TOUT');
const [hover, setHover] = useState(null); // { x, y, value, date }
const svgRef = useRef(null);
const wrapRef = useRef(null);
/* ── 1. Cumul complet (toutes les données) ── */
const allPoints = useMemo(() => {
if (!rows?.length) return [];
const byDate = {};
for (const r of rows) {
const d = r.date_operation.slice(0, 10);
byDate[d] = (byDate[d] || 0) + (r.type === 'depot' ? r.montant : -r.montant);
}
let cum = 0;
return Object.keys(byDate).sort().map(date => {
cum += byDate[date];
return { date, value: cum };
});
}, [rows]);
const todayStr = new Date().toISOString().slice(0, 10);
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 < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value });
return pts;
}
const now = new Date();
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 < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value });
return pts;
}, [allPoints, range, todayStr]);
/* ── 3. SVG dimensions ── */
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;
/* ── 4. É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);
// Inclure 0 pour ancrer l'axe ; ajouter 10 % de padding
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 + (hi > 0 ? pad : 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;
// Y ticks : 5 niveaux couvrant la plage réelle
const step = (scaleHi - scaleLo) / 4;
const yTicks = Array.from({ length: 5 }, (_, i) => ({ v: scaleLo + step * i, y: yScale(scaleLo + step * i) }));
// X ticks : max 8
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, plotW, plotH, PAD]);
/* ── 5. 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)}`;
}
// Refermer l'aire sur la ligne zéro (et non le bas du chart)
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, PAD, plotH]);
/* ── 6. 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);
// Find nearest point
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 flottant : DOIT être avant tout return conditionnel (Rules of Hooks) ── */
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;
/* ── Date affichée dans l'en-tête (hover ou aujourd'hui) ── */
const displayDate = hover
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })
: fmtTodayFull();
const displayValue = hover ? fmtValueDisplay(hover.value) : fmtValueDisplay(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">{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="sg-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={GOLD} stopOpacity="0.22" />
<stop offset="70%" stopColor={GOLD} stopOpacity="0.06" />
<stop offset="100%" stopColor={GOLD} stopOpacity="0" />
</linearGradient>
<filter id="sg-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="rgba(255,255,255,0.18)" strokeWidth="1" />
)}
{/* Remplissage dégradé */}
<path d={areaPath} fill="url(#sg-fill)" />
{/* Ligne principale */}
<path d={linePath} fill="none" stroke={GOLD} strokeWidth="1"
filter="url(#sg-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>
);
})}
{/* Ligne verticale + point hover */}
{hover && (
<g>
<line x1={hover.x} y1={PAD.top} x2={hover.x} y2={PAD.top + plotH}
stroke="rgba(255,255,255,0.12)" strokeWidth="1" strokeDasharray="3 4" />
<circle cx={hover.x} cy={hover.y} r="4.5"
fill={GOLD} 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>
);
}
@@ -0,0 +1,499 @@
import { useEffect, useState, useRef, useMemo } from 'react';
import { api } from '../api.js';
import { useInteretsChart } from '../context/InteretsChartContext.jsx';
import { fmtEUR, fmtPct } from '../utils/format.js';
const ICONS_BASE = '/api/icons-files/';
const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
function hexToRgba(hex, a) {
if (!hex || hex.length < 7) return `rgba(79,168,232,${a})`;
const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16);
return `rgba(${r},${g},${b},${a})`;
}
function ChevronDown({ size = 10 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9l6 6 6-6"/>
</svg>
);
}
/* ── Fusionne deux maps de remboursements ou projections ── */
function mergeMaps(mapA, mapB) {
const result = {};
const keys = new Set([...Object.keys(mapA || {}), ...Object.keys(mapB || {})]);
for (const k of keys) {
const a = mapA?.[k] || {};
const b = mapB?.[k] || {};
result[k] = {
interets_bruts: (a.interets_bruts || 0) + (b.interets_bruts || 0),
interets_nets: (a.interets_nets || 0) + (b.interets_nets || 0),
cashback: (a.cashback || 0) + (b.cashback || 0),
capital: (a.capital || 0) + (b.capital || 0),
interets_prevus: (a.interets_prevus || 0) + (b.interets_prevus || 0),
capital_prevu: (a.capital_prevu || 0) + (b.capital_prevu || 0),
};
}
return result;
}
export default function TableauInteretsPlateforme({ activeView, activeId, pfuRates, onCapitalMensuel, expandButton, onCellClick, activeCell }) {
const {
annee, setAnnee, availableYears,
inclureInterets, setInclureInterets,
inclureCapital, setInclureCapital,
inclureCashback, setInclureCashback,
netMode,
showActual, toggleActual,
showProjected, toggleProjected,
modeGlobal, toggleModeGlobal,
currentYear, currentMonth,
chartInterets, chartCapital, chartCashback,
} = useInteretsChart();
const [data, setData] = useState(null);
const [libIcons, setLibIcons] = useState({});
const [windowStart, setWindowStart] = useState(0);
const initializedRef = useRef(false);
/* ── Toggle consolidation détenteurs (clé partagée avec CapitalMensuelTable) ── */
const [groupByNom, setGroupByNom] = useState(() => {
try { return localStorage.getItem('cl_tip_group_by_nom') === 'true'; } catch { return false; }
});
const toggleGroupByNom = () => {
setGroupByNom(v => {
const next = !v;
try { localStorage.setItem('cl_tip_group_by_nom', String(next)); } catch {}
return next;
});
};
/* ── Icônes bibliothèque ─────────────────────────────────────── */
useEffect(() => {
api.get('/icons').then(rows => {
const m = {};
rows.forEach(r => { m[r.name] = r.filename; });
setLibIcons(m);
}).catch(() => {});
}, []);
/* ── Fenêtre années ──────────────────────────────────────────── */
const canPrev = windowStart > 0;
const canNext = windowStart + 3 < availableYears.length;
const visibleYears = availableYears.length ? availableYears.slice(windowStart, windowStart + 3) : [annee];
useEffect(() => {
if (!availableYears.length || initializedRef.current) return;
initializedRef.current = true;
const idx = availableYears.indexOf(annee);
const safe = idx >= 0 ? idx : availableYears.length - 1;
setWindowStart(Math.max(0, Math.min(availableYears.length - 3, safe - 1)));
}, [availableYears]);
/* ── Réduction PFU ───────────────────────────────────────────── */
const pfuReduction = useMemo(() => {
if (!pfuRates?.length) return 0;
const r = pfuRates.find(r => r.annee === annee)
?? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]);
return (r.prelev_sociaux + r.impot_revenu) / 100;
}, [pfuRates, annee]);
/* ── Fetch données ───────────────────────────────────────────── */
useEffect(() => {
if (modeGlobal) { setData(null); onCapitalMensuel?.([]); return; }
const params = { annee, ...(activeView === 'all' ? { scope: 'all' } : {}) };
api.get('/dashboard/interets-par-plateforme', params)
.then(d => { setData(d); onCapitalMensuel?.(d.capitalMensuel ?? []); })
.catch(() => {});
}, [annee, activeView, activeId, modeGlobal]);
/* ── Helpers affichage ───────────────────────────────────────── */
const AppIcon = ({ name, size = 28, active = false }) => {
const filename = libIcons[name];
if (filename) return (
<img src={ICONS_BASE + filename} className="app-lib-icon" width={size} height={size}
aria-hidden="true"
style={{ opacity: active ? 1 : 0.35, display: 'block', transition: 'opacity .15s' }} />
);
return <span style={{ width: size, height: size, display: 'block', borderRadius: 4,
background: 'var(--text-muted)', opacity: active ? 0.55 : 0.2, transition: 'opacity .15s' }} />;
};
const plateformes = data?.plateformes ?? [];
const capitalMensuel = data?.capitalMensuel ?? [];
// N'afficher le détenteur que s'il y en a plusieurs distincts (pattern multiDetenteur)
const multiDetenteur = new Set(plateformes.map(p => p.detenteur_nom).filter(Boolean)).size > 1;
/* ── Consolidation par nom si demandée ──────────────────────── */
const displayPlateformes = useMemo(() => {
if (!groupByNom || !multiDetenteur) return plateformes;
const byNom = {};
for (const plat of plateformes) {
if (!byNom[plat.nom]) {
byNom[plat.nom] = {
...plat,
id: plat.nom,
detenteur_nom: null,
rembourses: { ...plat.rembourses },
projections: { ...plat.projections },
};
} else {
byNom[plat.nom].rembourses = mergeMaps(byNom[plat.nom].rembourses, plat.rembourses);
byNom[plat.nom].projections = mergeMaps(byNom[plat.nom].projections, plat.projections);
}
}
return Object.values(byNom);
}, [plateformes, groupByNom, multiDetenteur]);
if (modeGlobal || !data || plateformes.length === 0) {
return null;
}
/* ── Valeurs par plateforme/mois ────────────────────────────────
* getCellValue : pour l'affichage (interets + cashback + capital selon toggles)
* getPerfValue : pour la performance (interets + cashback uniquement, jamais capital)
* ─────────────────────────────────────────────────────────────── */
const buildValue = (plat, mIdx, { withCapital }) => {
const m = mIdx + 1;
const moisStr = `${annee}-${String(m).padStart(2, '0')}`;
const isFuture = annee > currentYear || (annee === currentYear && m > currentMonth);
const isCurrent = annee === currentYear && m === currentMonth;
if (isFuture) {
if (!showProjected) return null;
const proj = plat.projections[moisStr];
if (!proj) return null;
let v = 0;
if (inclureInterets) v += netMode ? proj.interets_prevus * (1 - pfuReduction) : proj.interets_prevus;
if (withCapital && inclureCapital) v += proj.capital_prevu ?? 0;
return v > 0 ? { value: v, projected: true } : null;
}
if (isCurrent) {
const remb = plat.rembourses[moisStr];
const proj = plat.projections[moisStr];
let real = 0;
if (showActual && remb) {
if (inclureInterets) real += netMode ? remb.interets_nets : remb.interets_bruts;
if (inclureCashback) real += remb.cashback ?? 0;
if (withCapital && inclureCapital) real += remb.capital ?? 0;
}
let projAmt = 0;
// Les projections backend sont déjà filtrées NOT EXISTS par investissement → pas de double-comptage
if (showProjected && proj) {
if (inclureInterets) projAmt += netMode ? proj.interets_prevus * (1 - pfuReduction) : proj.interets_prevus;
if (withCapital && inclureCapital) projAmt += proj.capital_prevu ?? 0;
}
const val = real + projAmt;
return val > 0 ? { value: val, projected: projAmt > 0 } : null;
}
// Mois passé
if (!showActual) return null;
const remb = plat.rembourses[moisStr];
if (!remb) return null;
let v = 0;
if (inclureInterets) v += netMode ? remb.interets_nets : remb.interets_bruts;
if (inclureCashback) v += remb.cashback ?? 0;
if (withCapital && inclureCapital) v += remb.capital ?? 0;
return v > 0 ? { value: v, projected: false } : null;
};
const getCellValue = (plat, mIdx) => buildValue(plat, mIdx, { withCapital: true });
const getPerfValue = (plat, mIdx) => buildValue(plat, mIdx, { withCapital: false });
/* ── Grille ──────────────────────────────────────────────────── */
const grid = displayPlateformes.map(plat => ({
...plat,
months: Array.from({ length: 12 }, (_, i) => getCellValue(plat, i)),
}));
const monthTotals = Array.from({ length: 12 }, (_, i) =>
grid.reduce((s, row) => s + (row.months[i]?.value ?? 0), 0));
const platTotals = grid.map(row =>
row.months.reduce((s, v) => s + (v?.value ?? 0), 0));
const grandTotal = monthTotals.reduce((s, v) => s + v, 0);
/* Totaux pour la performance : intérêts + cashback uniquement (sans capital) */
const perfMonthTotals = Array.from({ length: 12 }, (_, i) =>
displayPlateformes.reduce((s, plat) => s + (getPerfValue(plat, i)?.value ?? 0), 0));
const perfGrandTotal = perfMonthTotals.reduce((s, v) => s + v, 0);
/* ── Capital et performances ─────────────────────────────────── */
const capitalValues = capitalMensuel.map(c => c.capital);
const nonZeroCap = capitalValues.filter(v => v > 0);
const avgCapital = nonZeroCap.length ? nonZeroCap.reduce((s, v) => s + v, 0) / nonZeroCap.length : 0;
const lastCapital = [...capitalValues].reverse().find(v => v > 0) ?? avgCapital;
const perfMensuelle = Array.from({ length: 12 }, (_, i) =>
capitalValues[i] > 0 ? perfMonthTotals[i] / capitalValues[i] : null);
const perfAnnualisee = perfMensuelle.map(p => p !== null ? p * 12 : null);
const perfAnnTotale = lastCapital > 0 ? perfGrandTotal / lastCapital : null;
/* ── Label total header ──────────────────────────────────────── */
const activeTypes = [
inclureInterets && { color: chartInterets, label: netMode ? 'Intérêts nets' : 'Intérêts bruts' },
inclureCapital && { color: chartCapital, label: 'Capital' },
inclureCashback && { color: chartCashback, label: 'Cashback' },
].filter(Boolean);
/* ── Rendu ───────────────────────────────────────────────────── */
return (
<div className="solde-chart-wrap" style={{ padding: '24px 24px 16px', marginBottom: 24 }}>
{/* ── Header identique au bar chart ── */}
<div className="solde-chart-header">
<div className="solde-chart-info">
<div style={{ display:'flex', alignItems:'center', gap:5, flexWrap:'wrap', marginBottom:2 }}>
{inclureInterets && (
<span style={{ display:'inline-flex', alignItems:'center', gap:4,
background:hexToRgba(chartInterets,0.12), borderRadius:5, padding:'3px 8px' }}>
<span style={{ width:7, height:7, borderRadius:2, background:chartInterets, flexShrink:0 }}/>
<span style={{ fontSize:13, color:chartInterets, fontWeight:600 }}>
{netMode ? 'Intérêts nets' : 'Intérêts bruts'}
</span>
</span>
)}
{inclureCapital && (
<span style={{ display:'inline-flex', alignItems:'center', gap:4,
background:hexToRgba(chartCapital,0.12), borderRadius:5, padding:'3px 8px' }}>
<span style={{ width:7, height:7, borderRadius:2, background:chartCapital, flexShrink:0 }}/>
<span style={{ fontSize:13, color:chartCapital, fontWeight:600 }}>Capital</span>
</span>
)}
{inclureCashback && (
<span style={{ display:'inline-flex', alignItems:'center', gap:4,
background:hexToRgba(chartCashback,0.12), borderRadius:5, padding:'3px 8px' }}>
<span style={{ width:7, height:7, borderRadius:2, background:chartCashback, flexShrink:0 }}/>
<span style={{ fontSize:13, color:chartCashback, fontWeight:600 }}>Cashback</span>
</span>
)}
{!inclureInterets && !inclureCapital && !inclureCashback && (
<span style={{ fontSize:13, color:'var(--text-muted)' }}></span>
)}
<span style={{ fontSize:13, color:'var(--text-muted)' }}>· {annee}</span>
</div>
<div className="solde-chart-value">{fmtEUR(grandTotal)}</div>
</div>
<div className="solde-chart-controls">
{/* Bouton intérêts */}
<button
title={inclureInterets ? 'Intérêts inclus — cliquer pour exclure' : 'Cliquer pour inclure les intérêts'}
onClick={() => setInclureInterets(v => !v)}
style={{ background: inclureInterets ? hexToRgba(chartInterets,0.13) : 'none',
border:'1px solid '+(inclureInterets ? chartInterets : 'transparent'),
borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center',
transition:'background .15s,border-color .15s', marginRight:2 }}>
<AppIcon name="interets" active={inclureInterets} />
</button>
{/* Bouton capital */}
<button
title={inclureCapital ? 'Capital inclus — cliquer pour exclure' : 'Cliquer pour inclure le capital'}
onClick={() => setInclureCapital(v => !v)}
style={{ background: inclureCapital ? hexToRgba(chartCapital,0.13) : 'none',
border:'1px solid '+(inclureCapital ? chartCapital : 'transparent'),
borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center',
transition:'background .15s,border-color .15s', marginRight:2 }}>
<AppIcon name="capital" active={inclureCapital} />
</button>
{/* Bouton cashback */}
<button
title={inclureCashback ? 'Cashback inclus — cliquer pour exclure' : 'Cliquer pour inclure le cashback'}
onClick={() => setInclureCashback(v => !v)}
style={{ background: inclureCashback ? hexToRgba(chartCashback,0.13) : 'none',
border:'1px solid '+(inclureCashback ? chartCashback : 'transparent'),
borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center',
transition:'background .15s,border-color .15s', marginRight:2 }}>
<AppIcon name="cashback" active={inclureCashback} />
</button>
{/* Sélecteur d'années */}
<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${modeGlobal ? ' active' : ''}`}
onClick={() => toggleModeGlobal()}>
TOUT
</button>
{expandButton}
</div>
</div>
</div>
{/* ── Tableau ── */}
<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">
<span style={{ display:'flex', alignItems:'center', gap:6 }}>
Plateforme
{multiDetenteur && (
<button
onClick={() => toggleGroupByNom()}
title={groupByNom ? 'Vue consolidée — cliquer pour détailler par détenteur' : 'Vue détaillée — cliquer pour consolider par plateforme'}
style={{
display:'inline-flex', alignItems:'center', gap:3,
background:'rgba(255,255,255,0.15)', border:'1px solid rgba(255,255,255,0.3)',
borderRadius:4, padding:'2px 5px', cursor:'pointer',
fontSize:10, fontWeight:600, color:'#fff', letterSpacing:'.03em',
lineHeight:1.4, whiteSpace:'nowrap', transition:'background .15s',
}}>
{groupByNom ? 'Consolidé' : 'Détaillé'}
<span style={{ display:'inline-flex', transition:'transform .2s', transform: groupByNom ? 'rotate(180deg)' : 'rotate(0deg)' }}>
<ChevronDown size={9} />
</span>
</button>
)}
</span>
</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">Total</th>
<th className="tip-th-avg">Moy. mensuelle</th>
</tr>
</thead>
<tbody>
{grid.map((plat, pi) => (
<tr key={plat.id} className="tip-row-plat">
<td className="tip-td-name">
{plat.nom}
{!groupByNom && 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) => {
const isCurrent = annee === currentYear && mi === currentMonth - 1;
const cellKey = `${plat.id}:${annee}-${String(mi + 1).padStart(2,'0')}`;
const isActive = activeCell?.key === cellKey;
const clickable = !!v;
return (
<td key={mi}
className={`tip-td-num${v?.projected ? ' tip-projected' : ''}${isCurrent ? ' tip-col-current' : ''}${isActive ? ' tip-td-active' : ''}${clickable ? ' tip-td-clickable' : ''}`}
onClick={() => clickable && onCellClick && onCellClick({
key: cellKey,
platId: plat.id,
platNom: plat.nom,
annee,
mois: String(mi + 1).padStart(2, '0'),
moisLabel: MOIS_LONG[mi],
})}
>
{v ? fmtEUR(v.value) : <span className="tip-dash"></span>}
</td>
);
})}
<td className="tip-td-total">
{platTotals[pi] > 0 ? fmtEUR(platTotals[pi]) : <span className="tip-dash"></span>}
</td>
<td className="tip-td-avg">
{platTotals[pi] > 0 ? fmtEUR(platTotals[pi] / 12) : <span className="tip-dash"></span>}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="tip-footer-total">
<td className="tip-td-name">Toutes les plateformes</td>
{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(grandTotal)}</td>
<td className="tip-td-avg">{grandTotal > 0 ? fmtEUR(grandTotal / 12) : <span className="tip-dash"></span>}</td>
</tr>
<tr className="tip-footer-capital">
<td className="tip-td-name">Capital investi</td>
{capitalValues.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">{lastCapital > 0 ? fmtEUR(lastCapital) : <span className="tip-dash"></span>}</td>
<td className="tip-td-void" />
</tr>
<tr className="tip-footer-perf">
<td className="tip-td-name">{netMode ? "Performance nette mensuelle" : "Performance brute mensuelle"}</td>
{perfMensuelle.map((v, i) => (
<td key={i} className={`tip-td-num${annee === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`}>
{v !== null ? fmtPct(v * 100) : <span className="tip-dash"></span>}
</td>
))}
<td className="tip-td-total">
{perfAnnTotale !== null ? fmtPct((perfAnnTotale / 12) * 100) : <span className="tip-dash"></span>}
</td>
<td className="tip-td-void" />
</tr>
<tr className="tip-footer-perf">
<td className="tip-td-name">{netMode ? "Performance nette annualisée" : "Performance brute annualisée"}</td>
{perfAnnualisee.map((v, i) => (
<td key={i} className={`tip-td-num${annee === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`}>
{v !== null ? fmtPct(v * 100) : <span className="tip-dash"></span>}
</td>
))}
<td className="tip-td-total">
{perfAnnTotale !== null ? fmtPct(perfAnnTotale * 100) : <span className="tip-dash"></span>}
</td>
<td className="tip-td-void" />
</tr>
</tfoot>
</table>
</div>
{/* ── Sélecteur Reçu / Projeté ── */}
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginTop:12, gap:12, flexWrap:'wrap' }}>
<div style={{
display:'inline-flex',
background:'#f0f0f0',
borderRadius:8,
padding:3,
gap:2,
flexShrink:0,
}}>
{[
{ key:'actual', label:'Reçu', active:showActual, toggle:toggleActual },
{ key:'projected', label:'Projeté', active:showProjected, toggle:toggleProjected },
].map(btn => (
<button key={btn.key} onClick={() => btn.toggle()} style={{
border:'none', cursor:'pointer', padding:'5px 14px', borderRadius:6,
fontSize:'var(--fs-sm)',
fontWeight: btn.active ? 600 : 400,
background: btn.active ? '#ffffff' : 'transparent',
color: btn.active ? '#1a1a2e' : '#9ca3af',
boxShadow: btn.active ? '0 1px 3px rgba(0,0,0,0.13)' : 'none',
transition: 'all .15s', lineHeight: 1.4,
}}>{btn.label}</button>
))}
</div>
</div>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { useTheme } from '../context/ThemeContext.jsx';
const OPTIONS = [
{ mode: 'light', icon: '☀', label: 'Clair' },
{ mode: 'dark', icon: '☾', label: 'Sombre' },
{ mode: 'system', icon: '◐', label: 'Système' },
];
export default function ThemeSwitcher() {
const { mode, setMode } = useTheme();
return (
<div className="theme-switcher" role="group" aria-label="Thème">
{OPTIONS.map(o => (
<button
key={o.mode}
type="button"
className={mode === o.mode ? 'active' : ''}
onClick={() => setMode(o.mode)}
title={o.label}
aria-pressed={mode === o.mode}
>
<span aria-hidden="true">{o.icon}</span>
<span>{o.label}</span>
</button>
))}
</div>
);
}
+353
View File
@@ -0,0 +1,353 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';
import { useUi } from '../context/UiContext.jsx';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { memberInitials, memberLabel } from '../utils/format.js';
/* ── Icons ───────────────────────────────────────────────────── */
function IconUser() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>;
}
function IconLogout() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>;
}
function IconAdmin() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="9" cy="6" r="3"/><path d="M2 16c0-3.3 3.1-6 7-6"/><path d="M14 13l-1.5 1.5L14 16"/><circle cx="15.5" cy="14.5" r="2.5"/></svg>;
}
function IconSettings() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><line x1="4" y1="6" x2="20" y2="6"/><circle cx="8" cy="6" r="2" fill="var(--user-menu-bg, #1e2d4a)"/><line x1="4" y1="12" x2="20" y2="12"/><circle cx="16" cy="12" r="2" fill="var(--user-menu-bg, #1e2d4a)"/><line x1="4" y1="18" x2="20" y2="18"/><circle cx="10" cy="18" r="2" fill="var(--user-menu-bg, #1e2d4a)"/></svg>;
}
function IconAide() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>;
}
function IconChevronRight() {
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M9 18l6-6-6-6"/></svg>;
}
function IconChevronUp({ open }) {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ transition: 'transform .2s', transform: open ? 'rotate(0deg)' : 'rotate(180deg)', flexShrink: 0 }}
aria-hidden="true">
<path d="M18 15l-6-6-6 6"/>
</svg>
);
}
function IconCheck() {
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>;
}
function IconTeam() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>;
}
/* ── Avatars ─────────────────────────────────────────────────── */
function UserAvatar({ user, size = 36 }) {
const initials = user?.display_name
? user.display_name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
: (user?.email || '?')[0].toUpperCase();
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: 'linear-gradient(135deg, #1e40af 0%, #1e3a8a 100%)',
border: '2px solid rgba(74,222,128,.5)',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 700, fontSize: Math.round(size * 0.38), flexShrink: 0,
letterSpacing: '.02em', userSelect: 'none',
}}>
{initials}
</div>
);
}
function MemberAvatar({ member, size = 28 }) {
const isEntreprise = member?.type === 'entreprise';
const bg = isEntreprise
? 'linear-gradient(135deg, #3730a3, #4338ca)'
: 'linear-gradient(135deg, #1e3a8a, #1e40af)';
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: bg, color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 700, fontSize: Math.round(size * 0.38), flexShrink: 0,
letterSpacing: '.02em', userSelect: 'none',
}}>
{memberInitials(member)}
</div>
);
}
function TeamBadge({ size = 28 }) {
return (
<div className="team-badge" style={{
width: size, height: size, borderRadius: '50%',
background: '#1e3a8a',
border: '1.5px solid rgba(255,255,255,.25)',
color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<IconTeam />
</div>
);
}
/* ── Composant principal ─────────────────────────────────────── */
export default function UserMenu() {
const { user, logout, isAdmin } = useAuth();
const { sidebarCollapsed } = useUi();
const { investisseurs, activeView, activeViewMember, setActiveView } = useInvestisseur();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [subOpen, setSubOpen] = useState(false);
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 famille = investisseurs.filter(i => i.type !== 'entreprise');
const entreprises = investisseurs.filter(i => i.type === 'entreprise');
const POPUP_W = 250;
const SUB_W = 230;
/* ── Calcul position (fixed = échappe overflow:hidden) ────── */
const computePosition = useCallback(() => {
if (!triggerRef.current) return;
const r = triggerRef.current.getBoundingClientRect();
let mainLeft, mainBottom, mainWidth;
if (sidebarCollapsed) {
mainLeft = r.right + 8;
mainBottom = window.innerHeight - r.bottom;
mainWidth = POPUP_W;
} else {
mainLeft = r.left;
mainBottom = window.innerHeight - r.top + 6;
mainWidth = r.width;
}
setPopupStyle({
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]);
const openMenu = useCallback(() => { computePosition(); setOpen(true); }, [computePosition]);
const closeMenu = useCallback(() => { setOpen(false); setSubOpen(false); }, []);
const clearClose = () => clearTimeout(closeTimer.current);
const scheduleClose = () => { closeTimer.current = setTimeout(closeMenu, 200); };
/* Fermeture clic extérieur */
useEffect(() => {
if (!open) return;
const onDown = (e) => {
const inTrigger = triggerRef.current?.contains(e.target);
const inPopup = popupRef.current?.contains(e.target);
const inSub = subRef.current?.contains(e.target);
if (!inTrigger && !inPopup && !inSub) closeMenu();
};
document.addEventListener('mousedown', onDown);
return () => document.removeEventListener('mousedown', onDown);
}, [open, closeMenu]);
/* Recalcul si sidebar change */
useEffect(() => { if (open) computePosition(); }, [sidebarCollapsed, open, computePosition]);
/* Handlers ouverture */
const onWrapEnter = () => { if (sidebarCollapsed) { clearClose(); openMenu(); } };
const onWrapLeave = () => { if (sidebarCollapsed) scheduleClose(); };
const onTriggerClick = () => { if (!sidebarCollapsed) { open ? closeMenu() : openMenu(); } };
const go = (path) => { closeMenu(); navigate(path); };
const handleLogout = () => { closeMenu(); logout(); navigate('/login'); };
const selectView = (v) => {
setActiveView(v);
closeMenu();
};
/* ── Libellés ────────────────────────────────────────────── */
const viewLabel = activeView === 'all'
? 'Famille et entreprises'
: (activeViewMember ? memberLabel(activeViewMember) : 'Famille et entreprises');
const TriggerBadge = activeView === 'all'
? <TeamBadge size={30} />
: <MemberAvatar member={activeViewMember} size={30} />;
return (
<div
className="user-menu-wrap"
onMouseEnter={onWrapEnter}
onMouseLeave={onWrapLeave}
>
{/* ── Main Popup (portail → échappe tout stacking context) ── */}
{open && createPortal(
<div
ref={popupRef}
id="user-menu-popup"
className="user-menu-popup"
style={popupStyle}
role="menu"
onMouseEnter={clearClose}
onMouseLeave={() => { if (sidebarCollapsed) scheduleClose(); }}
>
{/* En-tête compte utilisateur */}
<div className="user-menu-header">
<UserAvatar user={user} size={40} />
<div style={{ overflow: 'hidden', flex: 1 }}>
{user?.display_name && <div className="user-menu-name">{user.display_name}</div>}
<div className="user-menu-email">{user?.email}</div>
</div>
</div>
<div className="user-menu-sep" />
{/* Section Vue du profil */}
<div className="user-menu-section-header">Vue du profil</div>
<button
className={`user-menu-item user-menu-vue-row${subOpen ? ' active' : ''}`}
role="menuitem"
onClick={() => setSubOpen(s => !s)}
>
{activeView === 'all'
? <TeamBadge size={24} />
: <MemberAvatar member={activeViewMember} size={24} />
}
<span className="user-menu-vue-name">{viewLabel}</span>
<span style={{ color: '#4a6490', display: 'flex' }}><IconChevronRight /></span>
</button>
<div className="user-menu-sep" />
<button className="user-menu-item" role="menuitem" onClick={() => go('/compte')}>
<IconUser /> Mon compte
</button>
{isAdmin && (
<button className="user-menu-item" role="menuitem" onClick={() => go('/admin')}>
<IconAdmin /> Administration
</button>
)}
<button className="user-menu-item" role="menuitem" onClick={() => go('/settings')}>
<IconSettings /> Paramètres
</button>
<button className="user-menu-item" role="menuitem" onClick={() => go('/aide')}>
<IconAide /> Aide
</button>
<div className="user-menu-sep" />
<button className="user-menu-item danger" role="menuitem" onClick={handleLogout}>
<IconLogout /> Se déconnecter
</button>
</div>,
document.body
)}
{/* ── Sub-panel Vue du profil (portail) ───────────────────── */}
{open && subOpen && createPortal(
<div
ref={subRef}
className="user-menu-subpanel"
style={subStyle}
onMouseEnter={clearClose}
onMouseLeave={() => { if (sidebarCollapsed) scheduleClose(); }}
>
<div className="user-menu-subpanel-title">Vue du profil</div>
{/* Famille et entreprises (vue agrégée) */}
<button
className={`user-menu-profile-item${activeView === 'all' ? ' selected' : ''}`}
onClick={() => selectView('all')}
>
<TeamBadge size={26} />
<span className="user-menu-profile-name">Famille et entreprises</span>
{activeView === 'all' && <IconCheck />}
</button>
{/* Membres famille */}
{famille.length > 0 && (
<>
<div className="user-menu-subpanel-section">
{famille.length > 1 ? 'Profils' : 'Profil'}
</div>
{famille.map(m => (
<button
key={m.id}
className={`user-menu-profile-item${String(activeView) === String(m.id) ? ' selected' : ''}`}
onClick={() => selectView(String(m.id))}
>
<MemberAvatar member={m} size={26} />
<span className="user-menu-profile-name">{memberLabel(m)}</span>
{String(activeView) === String(m.id) && <IconCheck />}
</button>
))}
</>
)}
{/* Entreprises */}
{entreprises.length > 0 && (
<>
<div className="user-menu-subpanel-section">
{entreprises.length > 1 ? 'Entreprises' : 'Entreprise'}
</div>
{entreprises.map(m => (
<button
key={m.id}
className={`user-menu-profile-item${String(activeView) === String(m.id) ? ' selected' : ''}`}
onClick={() => selectView(String(m.id))}
>
<MemberAvatar member={m} size={26} />
<span className="user-menu-profile-name">{memberLabel(m)}</span>
{String(activeView) === String(m.id) && <IconCheck />}
</button>
))}
</>
)}
<div className="user-menu-sep" style={{ margin: '6px 0' }} />
<button
className="user-menu-profile-manage"
onClick={() => go('/compte?section=famille')}
>
Gérer les profils
</button>
</div>,
document.body
)}
{/* ── Déclencheur (bas de sidebar) ─────────────────────── */}
<button
ref={triggerRef}
className="user-menu-trigger"
onClick={onTriggerClick}
aria-haspopup="menu"
aria-expanded={open}
title={viewLabel}
>
{TriggerBadge}
{!sidebarCollapsed && (
<div className="user-menu-trigger-info">
<span className="user-menu-trigger-name">{viewLabel}</span>
</div>
)}
{!sidebarCollapsed && <IconChevronUp open={open} />}
</button>
</div>
);
}