Initial commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 · 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 % · Prélèvements effectués à la source par les plateformes · 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }}>‹</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>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -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' };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 :
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user