Initial commit

This commit is contained in:
Olivier CROGUENNEC
2026-06-13 14:57:15 +02:00
commit 48ed7fe65e
209 changed files with 49979 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from './context/AuthContext.jsx';
import Login from './pages/Login.jsx';
import Register from './pages/Register.jsx';
import Layout from './components/Layout.jsx';
import Dashboard from './pages/Dashboard.jsx';
import DepotsRetraits from './pages/DepotsRetraits.jsx';
import Investissements from './pages/Investissements.jsx';
import InvestissementDetail from './pages/InvestissementDetail.jsx';
import Remboursements from './pages/Remboursements.jsx';
import SimulRemboursements from './pages/SimulRemboursements.jsx';
import TaxReport from './pages/TaxReport.jsx';
import Settings from './pages/Settings.jsx';
import MonCompte from './pages/MonCompte.jsx';
import Admin from './pages/Admin.jsx';
import AdminPlateformes from './pages/AdminPlateformes.jsx';
import AdminFiscalite from './pages/AdminFiscalite.jsx';
import Aide from './pages/Aide.jsx';
import PlatformeProfile from './pages/PlatformeProfile.jsx';
import Plateformes from './pages/Plateformes.jsx';
function Protected({ children }) {
const { token, loading } = useAuth();
if (loading) return <div style={{ padding: 32 }}>Chargement</div>;
if (!token) return <Navigate to="/login" replace />;
return children;
}
function AdminOnly({ children }) {
const { isAdmin, loading } = useAuth();
if (loading) return <div style={{ padding: 32 }}>Chargement</div>;
if (!isAdmin) return <Navigate to="/" replace />;
return children;
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route element={<Protected><Layout /></Protected>}>
<Route index element={<Dashboard />} />
<Route path="plateformes" element={<Plateformes />} />
<Route path="depots-retraits" element={<DepotsRetraits />} />
<Route path="investissements" element={<Investissements />} />
<Route path="investissements/:id" element={<InvestissementDetail />} />
<Route path="remboursements" element={<Remboursements />} />
<Route path="simul" element={<SimulRemboursements />} />
<Route path="taxreport" element={<TaxReport />} />
<Route path="2778-sd" element={<Navigate to="/taxreport" replace />} />
<Route path="imports" element={<Navigate to="/settings?section=imports" replace />} />
<Route path="settings" element={<Settings />} />
<Route path="preferences" element={<Navigate to="/settings?section=apparence" replace />} />
<Route path="compte" element={<MonCompte />} />
<Route path="admin" element={<AdminOnly><Admin /></AdminOnly>} />
<Route path="admin/plateformes" element={<AdminOnly><AdminPlateformes /></AdminOnly>} />
<Route path="admin/fiscalite" element={<AdminOnly><AdminFiscalite /></AdminOnly>} />
<Route path="aide" element={<Aide />} />
<Route path="referentiel/:id" element={<PlatformeProfile />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
);
}
+67
View File
@@ -0,0 +1,67 @@
// Tiny fetch wrapper. Reads token + investisseurId from localStorage.
const BASE = import.meta.env.VITE_API_URL || '/api';
function authHeaders() {
const token = localStorage.getItem('cl_token');
const investisseurId = localStorage.getItem('cl_investisseur_id');
const h = {};
if (token) h['Authorization'] = `Bearer ${token}`;
if (investisseurId) h['X-Investisseur-Id'] = investisseurId;
return h;
}
async function handle(res) {
if (res.status === 204) return null;
const text = await res.text();
let body;
try { body = text ? JSON.parse(text) : null; } catch { body = text; }
if (!res.ok) {
const msg = (body && body.error) || res.statusText || 'Request failed';
const err = new Error(msg);
err.status = res.status;
err.details = body && body.details;
throw err;
}
return body;
}
export const api = {
get: (path, params) => {
const qs = params ? '?' + new URLSearchParams(
Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== '')
).toString() : '';
return fetch(BASE + path + qs, { headers: authHeaders() }).then(handle);
},
post: (path, body) =>
fetch(BASE + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(body),
}).then(handle),
put: (path, body) =>
fetch(BASE + path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(body),
}).then(handle),
patch: (path, body) =>
fetch(BASE + path, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(body),
}).then(handle),
del: (path) =>
fetch(BASE + path, { method: 'DELETE', headers: authHeaders() }).then(handle),
upload: (path, formData) =>
fetch(BASE + path, { method: 'POST', body: formData, headers: authHeaders() }).then(handle),
blob: (path) =>
fetch(BASE + path, { headers: authHeaders() }).then(async res => {
if (!res.ok) { const t = await res.text(); throw new Error(t || res.statusText); }
return res.blob();
}),
exportUrl: (path, params) => {
const qs = params ? '?' + new URLSearchParams(params).toString() : '';
return BASE + path + qs;
},
};
@@ -0,0 +1,351 @@
import { useMemo, useState } from 'react';
import { fmtEUR } from '../utils/format.js';
const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
/* ── Helpers dates ───────────────────────────────────────────────── */
function endOfMonth(Y, M) {
const d = new Date(Y, M, 0); // day 0 of month M+1 = last day of month M
return `${Y}-${String(M).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
}
function startOfMonth(Y, M) {
return `${Y}-${String(M).padStart(2,'0')}-01`;
}
function ChevronDown({ size = 10 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9l6 6 6-6"/>
</svg>
);
}
/* ── Composant ───────────────────────────────────────────────────── */
export default function CapitalMensuelTable({ allRows, allRembs, allReinvests, plats, expandButton }) {
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
const [annee, setAnnee] = useState(currentYear);
/* ── Toggle consolidation détenteurs ── */
const [groupByNom, setGroupByNom] = useState(() => {
try { return localStorage.getItem('cl_tip_group_by_nom') === 'true'; } catch { return false; }
});
const toggleGroupByNom = () => {
setGroupByNom(v => {
const next = !v;
try { localStorage.setItem('cl_tip_group_by_nom', String(next)); } catch {}
return next;
});
};
/* ── Années disponibles ── */
const availableYears = useMemo(() => {
const set = new Set(allRows.map(r => r.date_souscription?.slice(0,4)).filter(Boolean));
return [...set].map(Number).sort((a,b) => a - b);
}, [allRows]);
/* ── Precompute : reinvests et capital_remb par investissement ── */
const reinvestByInv = useMemo(() => {
const map = {};
for (const rv of allReinvests) {
const id = rv.investissement_id;
if (!id) continue;
if (!map[id]) map[id] = [];
map[id].push({ date: rv.date_reinvestissement?.slice(0,10), montant: rv.montant || 0 });
}
return map;
}, [allReinvests]);
const capRembByInv = useMemo(() => {
const map = {};
for (const rb of allRembs) {
const id = rb.investissement_id;
if (!id || rb.type !== 'normal') continue;
if (!map[id]) map[id] = [];
map[id].push({ date: rb.date_remb?.slice(0,10), capital: rb.capital || 0 });
}
return map;
}, [allRembs]);
const lastRembDateMap = useMemo(() => {
const map = {};
for (const rb of allRembs) {
const id = rb.investissement_id;
const d = rb.date_remb?.slice(0,10);
if (!id || !d) continue;
if (!map[id] || d > map[id]) map[id] = d;
}
return map;
}, [allRembs]);
/* ── Calcul capital encours par plateforme par mois ─────────────
* Pour chaque mois M :
* - L'investissement est actif si souscrit avant fin M
* ET (statut actif aujourd'hui OU date_fin >= début M)
* - capital = montant_investi + reinvests_≤_finM capital_remboursé_≤_finM
* ─────────────────────────────────────────────────────────────── */
const { grid, multiDetenteur } = useMemo(() => {
if (!allRows.length) return { grid: null, multiDetenteur: false };
const ACTIVE = ['en_cours', 'en_retard', 'procedure'];
// Index plateformes par id (pour nom + detenteur)
const platMap = {};
for (const p of plats) platMap[p.id] = p;
// Pour chaque investissement, capital encours au end of month M
const getCapitalAtEndOfMonth = (inv, Y, M) => {
const endM = endOfMonth(Y, M);
if (inv.date_souscription > endM) return 0;
const startM = startOfMonth(Y, M);
const isActive = ACTIVE.includes(inv.statut) ||
((inv.date_cible || lastRembDateMap[inv.id] || null) >= startM);
if (!isActive) return 0;
const reinvM = (reinvestByInv[inv.id] || [])
.filter(rv => rv.date && rv.date <= endM)
.reduce((s, rv) => s + rv.montant, 0);
const capRembM = (capRembByInv[inv.id] || [])
.filter(rb => rb.date && rb.date <= endM)
.reduce((s, rb) => s + rb.capital, 0);
return Math.max(0, inv.montant_investi + reinvM - capRembM);
};
// Agréger par plateforme (id)
const byPlat = {};
for (const inv of allRows) {
const pid = inv.plateforme_id;
if (!byPlat[pid]) {
const p = platMap[pid] || {};
byPlat[pid] = {
id: pid,
nom: inv.plateforme_nom || p.nom || '—',
investisseur_id: p.investisseur_id ?? inv.investisseur_id ?? null,
detenteur_nom: inv.plateforme_detenteur_nom || null,
months: Array(12).fill(0),
};
}
const row = byPlat[pid];
for (let m = 1; m <= 12; m++) {
row.months[m-1] += getCapitalAtEndOfMonth(inv, annee, m);
}
}
const allPlats = Object.values(byPlat).filter(p => p.months.some(v => v > 0));
// Détection multi-détenteur sur données brutes
const multi = new Set(allPlats.map(p => p.investisseur_id).filter(v => v != null)).size > 1;
// Consolidation par nom si demandée
let rows;
if (groupByNom && multi) {
const byNom = {};
for (const row of allPlats) {
if (!byNom[row.nom]) {
byNom[row.nom] = { id: row.nom, nom: row.nom, investisseur_id: null, detenteur_nom: null, months: [...row.months] };
} else {
for (let i = 0; i < 12; i++) byNom[row.nom].months[i] += row.months[i];
}
}
rows = Object.values(byNom);
} else {
rows = allPlats;
}
rows = rows
.filter(p => p.months.some(v => v > 0))
.sort((a,b) => b.months.reduce((s,v) => s+v, 0) - a.months.reduce((s,v) => s+v, 0));
return { grid: rows, multiDetenteur: multi };
}, [allRows, annee, reinvestByInv, capRembByInv, lastRembDateMap, plats, groupByNom]);
/* ── Totaux et moyennes ── */
const stats = useMemo(() => {
if (!grid) return null;
const monthTotals = Array.from({ length: 12 }, (_, i) =>
grid.reduce((s, row) => s + row.months[i], 0));
const grandTotal = monthTotals.reduce((s, v) => s + v, 0);
// Moyenne : average of non-zero months per platform
const platMoyennes = grid.map(row => {
const nonZero = row.months.filter(v => v > 0);
return nonZero.length ? nonZero.reduce((s,v) => s+v, 0) / nonZero.length : 0;
});
const totalMoyenne = platMoyennes.reduce((s,v) => s+v, 0);
const platPoids = platMoyennes.map(m => totalMoyenne > 0 ? (m / totalMoyenne) * 100 : 0);
const monthMoyennes = Array.from({ length: 12 }, (_, i) =>
grid.reduce((s, row) => s + row.months[i], 0));
const nonZeroMonthTotals = monthTotals.filter(v => v > 0);
const globalMoyenne = nonZeroMonthTotals.length
? nonZeroMonthTotals.reduce((s,v) => s+v, 0) / nonZeroMonthTotals.length
: 0;
return { monthTotals, grandTotal, platMoyennes, platPoids, totalMoyenne, monthMoyennes, globalMoyenne };
}, [grid]);
/* ── Sélecteur d'années ── */
const [windowStart, setWindowStart] = useState(() => {
const idx = availableYears.indexOf(currentYear);
return Math.max(0, Math.min(Math.max(0, availableYears.length - 3), (idx >= 0 ? idx : availableYears.length - 1) - 1));
});
const visibleYears = availableYears.length ? availableYears.slice(windowStart, windowStart + 3) : [annee];
const canPrev = windowStart > 0;
const canNext = windowStart + 3 < availableYears.length;
/* ── Rendu ─────────────────────────────────────────────────────── */
return (
<div className="solde-chart-wrap" style={{ padding: '24px 24px 16px', marginBottom: 24 }}>
{/* ── Header ── */}
<div className="solde-chart-header">
<div className="solde-chart-info">
<div style={{ display:'flex', alignItems:'center', gap:5, marginBottom:2 }}>
<span style={{ fontSize:13, color:'var(--text-muted)' }}>
{`Capital investi · ${annee}`}
</span>
</div>
<div className="solde-chart-value">
{stats ? fmtEUR(stats.globalMoyenne) : '—'}
<span style={{ fontSize:14, fontWeight:400, color:'var(--text-muted)', marginLeft:8 }}>moy. mensuelle</span>
</div>
</div>
{/* Sélecteur d'années */}
<div className="solde-chart-controls">
<div className="solde-chart-ranges">
<button className="solde-range-btn"
onClick={() => setWindowStart(w => Math.max(0, w-1))}
disabled={!canPrev} style={{ opacity: canPrev ? 1 : 0.3 }}></button>
{visibleYears.map(y => (
<button key={y}
className={`solde-range-btn${annee === y ? ' active' : ''}`}
onClick={() => setAnnee(y)}>
{y}
</button>
))}
<button className="solde-range-btn"
onClick={() => setWindowStart(w => Math.min(Math.max(0, availableYears.length - 3), w+1))}
disabled={!canNext} style={{ opacity: canNext ? 1 : 0.3 }}></button>
<button className={`solde-range-btn${annee === currentYear ? ' active' : ''}`}
onClick={() => setAnnee(currentYear)}>
TOUT
</button>
{expandButton}
</div>
</div>
</div>
{/* ── Table ── */}
{!grid || grid.length === 0 ? (
<div style={{ marginTop: 20, color: 'var(--text-muted)', fontSize: 'var(--fs-sm)', padding: '24px 0', textAlign: 'center' }}>
Aucun capital investi pour {annee}.
</div>
) : (
<div style={{ overflowX: 'auto', marginTop: 20, position: 'relative', zIndex: 0 }}>
<table className="tip-table">
<thead>
<tr>
<th className="tip-th-empty" />
<th className="tip-th-year" colSpan={12}>{annee}</th>
<th className="tip-th-empty" />
<th className="tip-th-empty" />
</tr>
<tr>
<th className="tip-th-name tip-th-name-amber">
<span style={{ display:'flex', alignItems:'center', gap:6 }}>
Plateforme
{multiDetenteur && (
<button
onClick={() => toggleGroupByNom()}
title={groupByNom ? 'Vue consolidée — cliquer pour détailler par détenteur' : 'Vue détaillée — cliquer pour consolider par plateforme'}
style={{
display:'inline-flex', alignItems:'center', gap:3,
background:'rgba(255,255,255,0.15)', border:'1px solid rgba(255,255,255,0.3)',
borderRadius:4, padding:'2px 5px', cursor:'pointer',
fontSize:10, fontWeight:600, color:'#fff', letterSpacing:'.03em',
lineHeight:1.4, whiteSpace:'nowrap', transition:'background .15s',
}}>
{groupByNom ? 'Consolidé' : 'Détaillé'}
<span style={{ display:'inline-flex', transition:'transform .2s', transform: groupByNom ? 'rotate(180deg)' : 'rotate(0deg)' }}>
<ChevronDown size={9} />
</span>
</button>
)}
</span>
</th>
{MOIS_LONG.map((m, i) => (
<th key={m}
className={`tip-th-month${annee === currentYear && i === currentMonth - 1 ? ' tip-th-month-current' : ''}`}>
{m}
</th>
))}
<th className="tip-th-total">Moyenne</th>
<th className="tip-th-avg">Poids</th>
</tr>
</thead>
<tbody>
{grid.map((plat, pi) => (
<tr key={plat.id} className="tip-row-plat">
<td className="tip-td-name">
{plat.nom}
{!groupByNom && multiDetenteur && plat.detenteur_nom && (
<span style={{ marginLeft: 6, fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>
{plat.detenteur_nom}
</span>
)}
</td>
{plat.months.map((v, mi) => (
<td key={mi}
className={`tip-td-num${annee === currentYear && mi === currentMonth - 1 ? ' tip-col-current' : ''}`}>
{v > 0 ? fmtEUR(v) : <span className="tip-dash"></span>}
</td>
))}
<td className="tip-td-total">
{stats.platMoyennes[pi] > 0 ? fmtEUR(stats.platMoyennes[pi]) : <span className="tip-dash"></span>}
</td>
<td className="tip-td-avg">
{stats.platPoids[pi] > 0 ? (
<div style={{ display:'flex', alignItems:'center', gap:5, justifyContent:'flex-end' }}>
<div style={{ width:36, height:4, borderRadius:2, background:'var(--surface-2)', overflow:'hidden' }}>
<div style={{ width:`${Math.min(100,stats.platPoids[pi])}%`, height:'100%', background:'var(--primary)', borderRadius:2 }} />
</div>
<span style={{ minWidth:38, textAlign:'right' }}>
{stats.platPoids[pi].toFixed(1)} %
</span>
</div>
) : <span className="tip-dash"></span>}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="tip-footer-total">
<td className="tip-td-name">Toutes les plateformes</td>
{stats.monthTotals.map((v, i) => (
<td key={i}
className={`tip-td-num${annee === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`}>
{v > 0 ? fmtEUR(v) : <span className="tip-dash"></span>}
</td>
))}
<td className="tip-td-total">{fmtEUR(stats.globalMoyenne)}</td>
<td className="tip-td-void" />
</tr>
</tfoot>
</table>
</div>
)}
</div>
);
}
+196
View File
@@ -0,0 +1,196 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { api } from '../api.js';
/**
* CategorySelect — multi-select with checkboxes + inline "Add category"
*
* Props:
* selected : number[] — ids sélectionnés
* onChange : (ids: number[]) => void
* categories : { id, nom }[] — liste complète fournie par le parent
* onCategoryAdded : ({ id, nom }) => void — appelé après création inline
*
* Le dropdown est rendu en position:fixed (calculé depuis getBoundingClientRect)
* pour échapper au overflow:auto des modales parentes.
*/
export default function CategorySelect({ selected = [], onChange, categories = [], onCategoryAdded }) {
const [open, setOpen] = useState(false);
const [adding, setAdding] = useState(false);
const [newName, setNewName] = useState('');
const [err, setErr] = useState(null);
const [busy, setBusy] = useState(false);
const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 0 });
const wrapRef = useRef(null);
const triggerRef = useRef(null);
/* ── Position fixe calculée à chaque ouverture ───────────────── */
useLayoutEffect(() => {
if (!open || !triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
setDropPos({
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
});
}, [open]);
/* ── Fermeture : clic extérieur + scroll + resize ────────────── */
useEffect(() => {
if (!open) return;
const close = (e) => {
if (wrapRef.current?.contains(e.target)) return;
// Exclure aussi le dropdown lui-même (rendu en fixed hors du wrap)
const drop = document.getElementById('cat-select-dropdown-portal');
if (drop?.contains(e.target)) return;
setOpen(false);
};
const closeOnScroll = (e) => {
const drop = document.getElementById('cat-select-dropdown-portal');
if (drop?.contains(e.target)) return;
setOpen(false);
};
document.addEventListener('mousedown', close);
window.addEventListener('scroll', closeOnScroll, true);
window.addEventListener('resize', closeOnScroll);
return () => {
document.removeEventListener('mousedown', close);
window.removeEventListener('scroll', closeOnScroll, true);
window.removeEventListener('resize', closeOnScroll);
};
}, [open]);
const toggle = (id) => {
onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]);
};
const addCategory = async (e) => {
e.preventDefault();
if (!newName.trim()) return;
setBusy(true); setErr(null);
try {
const cat = await api.post('/categories', { nom: newName.trim() });
onCategoryAdded(cat);
onChange([...selected, cat.id]);
setNewName('');
setAdding(false);
} catch (e) {
setErr(e.message);
} finally {
setBusy(false);
}
};
/* ── Label du bouton déclencheur ─────────────────────────────── */
const label = (() => {
if (selected.length === 0) return "Aucune catégorie d'investissement";
const names = categories.filter(c => selected.includes(c.id)).map(c => c.nom);
if (names.length <= 2) return names.join(', ');
return `${names.length} catégories d'invest.`;
})();
/* ── Dropdown rendu en position:fixed ────────────────────────── */
const dropdown = open ? (
<div
id="cat-select-dropdown-portal"
className="cat-select-dropdown"
role="listbox"
aria-multiselectable="true"
style={{
position: 'fixed',
top: dropPos.top,
left: dropPos.left,
width: dropPos.width,
zIndex: 9999,
}}
>
{/* Liste des catégories */}
{categories.length === 0 && (
<div className="cat-select-empty">Aucune catégorie d'investissement disponible</div>
)}
{categories.map(cat => {
const checked = selected.includes(cat.id);
return (
<label key={cat.id} className={`cat-select-item${checked ? ' checked' : ''}`}>
<input
type="checkbox"
checked={checked}
onChange={() => toggle(cat.id)}
/>
<span>{cat.nom}</span>
{checked && (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ marginLeft: 'auto', color: 'var(--accent)' }} aria-hidden="true">
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
</label>
);
})}
{/* Séparateur + ajout */}
<div className="cat-select-sep" />
{!adding ? (
<button type="button" className="cat-select-add-btn"
onClick={() => { setAdding(true); setErr(null); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
aria-hidden="true">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Ajouter une catégorie d'investissement
</button>
) : (
<form onSubmit={addCategory} className="cat-select-new-form">
<input
autoFocus
value={newName}
onChange={e => setNewName(e.target.value)}
placeholder="Nom de la catégorie d'investissement"
maxLength={100}
/>
<div className="cat-select-new-actions">
<button type="submit" className="primary" disabled={busy || !newName.trim()}>
{busy ? '…' : 'Créer'}
</button>
<button type="button" className="ghost"
onClick={() => { setAdding(false); setNewName(''); setErr(null); }}>
Annuler
</button>
</div>
{err && <div className="cat-select-err">{err}</div>}
</form>
)}
</div>
) : null;
return (
<>
<div ref={wrapRef} className="cat-select-wrap">
<button
ref={triggerRef}
type="button"
className={`cat-select-trigger${open ? ' open' : ''}`}
onClick={() => { setOpen(o => !o); setAdding(false); setErr(null); }}
aria-haspopup="listbox"
aria-expanded={open}
>
<span className="cat-select-label">{label}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ flexShrink: 0, transition: 'transform .15s', transform: open ? 'rotate(0deg)' : 'rotate(180deg)' }}
aria-hidden="true">
<path d="M18 15l-6-6-6 6"/>
</svg>
</button>
</div>
{/* Dropdown rendu hors du wrap pour échapper à overflow:auto */}
{dropdown}
</>
);
}
@@ -0,0 +1,349 @@
import { useEffect, useState } from 'react';
import { api } from '../api.js';
const MOIS_LABELS = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
const MOIS_NUMS = ['01','02','03','04','05','06','07','08','09','10','11','12'];
const r2 = v => Math.round((v ?? 0) * 100) / 100;
const fmtN = v => Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const fmtI = v => Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
const fullName = l => { const n = l.investisseur_nom ?? ''; const p = l.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); };
const BADGE_AUTO = { text: 'déclaration automatique', bg: 'rgba(22,163,74,0.1)', color: '#16a34a' };
const BADGE_DECL = { text: 'à déclarer', bg: 'rgba(239,68,68,0.1)', color: '#dc2626' };
function BadgeTag({ badge }) {
if (!badge) return null;
return (
<span style={{ fontSize: 10, padding: '1px 6px', borderRadius: 10, background: badge.bg, color: badge.color, fontWeight: 600, whiteSpace: 'nowrap', marginLeft: 6 }}>
{badge.text}
</span>
);
}
/* breakdown item: { nom, val, badge } */
function Case2042({ code, label, note, value, breakdown }) {
if (!value) return null;
return (
<div style={{ borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'grid', gridTemplateColumns: '90px 1fr auto', gap: '0 12px', alignItems: 'center', padding: '10px 14px' }}>
<div style={{ fontFamily: 'monospace', fontWeight: 800, fontSize: 'var(--fs-base)', color: 'var(--primary)', background: 'rgba(99,102,241,0.07)', borderRadius: 6, padding: '2px 6px', textAlign: 'center', letterSpacing: '.04em' }}>{code}</div>
<div>
<div style={{ fontSize: 'var(--fs-sm)', fontWeight: 600 }}>{label}</div>
{note && <div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 2 }}>{note}</div>}
</div>
<div style={{ fontWeight: 800, fontSize: 'var(--fs-lg)', whiteSpace: 'nowrap', color: 'var(--text)' }}>{fmtI(value)} </div>
</div>
{breakdown && breakdown.length > 0 && (
<div style={{ padding: '0 14px 10px 118px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{breakdown.map((p, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
<span style={{ display: 'flex', alignItems: 'center' }}>
{p.nom}
<BadgeTag badge={p.badge} />
</span>
<span style={{ fontWeight: 500, marginLeft: 12 }}>{fmtI(p.val)} </span>
</div>
))}
</div>
)}
</div>
);
}
export default function Cerfa2042Preview({ annee, activeView, pfoAssujetti }) {
const LS_EXCL = 'cl_2778_excluded_plats';
const [data2561, setData2561] = useState(null);
const [data2778, setData2778] = useState(null);
const [pfuList, setPfuList] = useState([]);
const [excluded, setExcluded] = useState(() => {
try { return new Set(JSON.parse(localStorage.getItem(LS_EXCL)) ?? []); }
catch { return new Set(); }
});
const [loading, setLoading] = useState(true);
const [view, setView] = useState('matrice');
const [filterMode, setFilterMode] = useState('all'); // 'all' | 'auto' | 'decl'
useEffect(() => {
setLoading(true);
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
Promise.all([
api.get('/taxreport/cerfa2561', { annee, ...scopeParams }),
pfuList.length === 0 ? api.get('/pfu') : Promise.resolve(null),
api.get('/taxreport/2778', { annee, ...scopeParams }),
]).then(([d2561, pfu, d2778]) => {
setData2561(d2561);
if (pfu) setPfuList(pfu);
if (d2778) setData2778(d2778);
})
.finally(() => setLoading(false));
}, [annee, activeView]); // eslint-disable-line
const frLignes = (data2561?.lignes ?? []).filter(l => l.domiciliation === 'FR');
const platEtr = (data2778?.plateformes ?? []).filter(p => !excluded.has(p.id));
const ratesForYear = () => {
const sorted = [...pfuList].sort((a, b) => b.annee - a.annee);
const m = sorted.find(r => r.annee <= Number(annee));
if (!m) return { pfo: 0.128, csg: 0.106, crds: 0.005, solidarite: 0.075 };
return { pfo: (m.impot_revenu ?? 12.8) / 100, csg: (m.csg ?? 10.6) / 100, crds: (m.crds ?? 0.5) / 100, solidarite: (m.solidarite ?? 7.5) / 100 };
};
const rates = ratesForYear();
const totalTaxRate = rates.pfo + rates.csg + rates.crds + rates.solidarite;
/* ── Suivi mensuel combiné ── */
const matriceView = (
<div style={{ overflowX: 'auto' }}>
{loading && <div className="card text-muted">Chargement</div>}
{!loading && frLignes.length === 0 && platEtr.length === 0 && (
<div className="card"><p className="text-muted" style={{ margin: 0 }}>Aucune donnée pour {annee}.</p></div>
)}
{!loading && (frLignes.length > 0 || platEtr.length > 0) && (
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, padding: '14px 14px 10px' }}>
<h4 style={{ margin: 0, fontWeight: 700 }}>Suivi mensuel des intérêts bruts</h4>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Revenus {annee} toutes plateformes</span>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--fs-xs)' }}>
<thead>
<tr style={{ background: 'var(--surface-2)', borderBottom: '1px solid var(--border)' }}>
<th style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 600, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Plateforme Détenteur</th>
{MOIS_LABELS.map(m => (
<th key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 600, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>{m}</th>
))}
<th style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 600, color: 'var(--text-muted)' }}>Total</th>
</tr>
</thead>
<tbody>
{frLignes.length > 0 && (
<tr style={{ background: 'rgba(22,163,74,0.05)' }}>
<td colSpan={14} style={{ padding: '4px 12px', fontSize: 'var(--fs-xs)', fontWeight: 600, color: '#16a34a', letterSpacing: '.04em', textTransform: 'uppercase' }}>
Plateformes françaises déclaration automatique
</td>
</tr>
)}
{frLignes.map(l => {
const total = r2(Object.values(l.mois ?? {}).reduce((s, m) => s + (m.interets_bruts ?? 0), 0));
return (
<tr key={`fr_${l.plateforme_id}_${l.investisseur_id}`} style={{ borderBottom: '1px solid var(--border)', background: 'transparent' }}>
<td style={{ padding: '6px 12px', fontWeight: 500, whiteSpace: 'nowrap' }}>
{l.plateforme_nom}
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}> {fullName(l)}</span>
</td>
{MOIS_NUMS.map(m => {
const v = l.mois?.[m]?.interets_bruts ?? 0;
return <td key={m} style={{ padding: '6px 8px', textAlign: 'right', color: v > 0 ? 'var(--text)' : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'}</td>;
})}
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700 }}>{total > 0 ? fmtN(total) + ' €' : '—'}</td>
</tr>
);
})}
{frLignes.length > 0 && (
<tr style={{ borderTop: '2px solid var(--border)', background: 'rgba(22,163,74,0.06)' }}>
<td style={{ padding: '6px 12px', fontWeight: 700, color: '#16a34a' }}>Total Plateformes françaises (brut)</td>
{MOIS_NUMS.map(m => {
const v = r2(frLignes.reduce((s, l) => s + (l.mois?.[m]?.interets_bruts ?? 0), 0));
return <td key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 700, color: v > 0 ? '#16a34a' : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'}</td>;
})}
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700, color: '#16a34a' }}>
{fmtN(r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + (m.interets_bruts ?? 0), 0)), 0)))}
</td>
</tr>
)}
{platEtr.length > 0 && (
<tr style={{ background: 'rgba(239,68,68,0.05)' }}>
<td colSpan={14} style={{ padding: '4px 12px', fontSize: 'var(--fs-xs)', fontWeight: 600, color: '#dc2626', letterSpacing: '.04em', textTransform: 'uppercase' }}>
Plateformes étrangères à déclarer
</td>
</tr>
)}
{platEtr.map(p => {
const total = r2(Object.values(p.mois ?? {}).reduce((s, v) => s + v, 0));
return (
<tr key={`etr_${p.id}`} style={{ borderBottom: '1px solid var(--border)', background: 'transparent' }}>
<td style={{ padding: '6px 12px', fontWeight: 500, whiteSpace: 'nowrap' }}>
{p.nom}
{(p.investisseur_nom || p.investisseur_prenom) && (
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}> {fullName(p)}</span>
)}
</td>
{MOIS_NUMS.map(m => {
const v = p.mois?.[m] ?? 0;
return <td key={m} style={{ padding: '6px 8px', textAlign: 'right', color: v > 0 ? 'var(--text)' : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'}</td>;
})}
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700 }}>{total > 0 ? fmtN(total) + ' €' : '—'}</td>
</tr>
);
})}
</tbody>
<tfoot>
{platEtr.length > 0 && (
<tr style={{ borderTop: '2px solid var(--border)', background: 'rgba(239,68,68,0.06)' }}>
<td style={{ padding: '6px 12px', fontWeight: 700, color: '#dc2626' }}>Total Plateformes étrangères (brut)</td>
{MOIS_NUMS.map(m => {
const v = r2(platEtr.reduce((s, p) => s + (p.mois?.[m] ?? 0), 0));
return <td key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 700, color: v > 0 ? '#dc2626' : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'}</td>;
})}
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700, color: '#dc2626' }}>
{fmtN(r2(platEtr.reduce((s, p) => s + r2(Object.values(p.mois ?? {}).reduce((ss, v) => ss + v, 0)), 0)))}
</td>
</tr>
)}
<tr style={{ borderTop: '2px solid var(--border)', background: 'var(--surface-2)' }}>
<td style={{ padding: '6px 12px', fontWeight: 700 }}>Total général (brut)</td>
{MOIS_NUMS.map(m => {
const v = r2(frLignes.reduce((s, l) => s + (l.mois?.[m]?.interets_bruts ?? 0), 0) + platEtr.reduce((s, p) => s + (p.mois?.[m] ?? 0), 0));
return <td key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 700, color: v > 0 ? 'var(--text)' : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'}</td>;
})}
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700 }}>
{fmtN(r2(
frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + (m.interets_bruts ?? 0), 0)), 0) +
platEtr.reduce((s, p) => s + r2(Object.values(p.mois ?? {}).reduce((ss, v) => ss + v, 0)), 0)
))}
</td>
</tr>
{/* Taux total prélevé */}
<tr style={{ background: 'transparent' }}>
<td style={{ padding: '4px 12px', color: 'var(--text-muted)', fontSize: 'var(--fs-xs)' }}>
Taux total prélevé ({(totalTaxRate * 100).toFixed(1)} %)
</td>
{MOIS_NUMS.map(m => {
const brut = r2(frLignes.reduce((s, l) => s + (l.mois?.[m]?.interets_bruts ?? 0), 0) + platEtr.reduce((s, p) => s + (p.mois?.[m] ?? 0), 0));
const prelevFR = r2(frLignes.reduce((s, l) => s + ((l.mois?.[m]?.prelev_sociaux ?? 0) + (l.mois?.[m]?.prelev_forfaitaire ?? 0)), 0));
const prelevEtr = r2(platEtr.reduce((s, p) => s + (p.mois?.[m] ?? 0), 0) * totalTaxRate);
const taux = brut > 0 ? ((prelevFR + prelevEtr) / brut * 100).toFixed(1) + ' %' : '—';
return <td key={m} style={{ padding: '4px 8px', textAlign: 'right', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>{taux}</td>;
})}
<td style={{ padding: '4px 12px', textAlign: 'right', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
{(totalTaxRate * 100).toFixed(1)} %
</td>
</tr>
{/* Total intérêt net */}
<tr style={{ background: 'rgba(22,163,74,0.06)', borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '6px 12px', fontWeight: 700, color: 'var(--success)' }}>Total intérêt net</td>
{MOIS_NUMS.map(m => {
const netFR = r2(frLignes.reduce((s, l) => {
const mo = l.mois?.[m];
return s + ((mo?.interets_bruts ?? 0) - (mo?.prelev_sociaux ?? 0) - (mo?.prelev_forfaitaire ?? 0));
}, 0));
const netEtr = r2(platEtr.reduce((s, p) => s + (p.mois?.[m] ?? 0), 0) * (1 - totalTaxRate));
const net = r2(netFR + netEtr);
return <td key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 700, color: net > 0 ? 'var(--success)' : 'var(--text-muted)' }}>{net > 0 ? fmtN(net) : '—'}</td>;
})}
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700, color: 'var(--success)' }}>
{fmtN(r2(
frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + ((m.interets_bruts ?? 0) - (m.prelev_sociaux ?? 0) - (m.prelev_forfaitaire ?? 0)), 0)), 0) +
platEtr.reduce((s, p) => s + r2(Object.values(p.mois ?? {}).reduce((ss, v) => ss + v, 0)) * (1 - totalTaxRate), 0)
))}
</td>
</tr>
<tr style={{ background: 'var(--surface-2)' }}>
<td colSpan={14} style={{ padding: '8px 12px', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', borderTop: '1px solid var(--border)' }}>
Plateformes françaises : prélèvements à la source (taux {(totalTaxRate * 100).toFixed(1)} %) déclaration automatique sur 2042 &nbsp;·&nbsp; Plateformes étrangères : à déclarer
</td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
);
/* ── Données 2042 — cases mixtes par code ── */
const données2042View = (() => {
if (loading) return <div className="card text-muted">Chargement</div>;
// Totaux FR
const fr2TT = frLignes.reduce((s, l) => s + (l.case_2TT ?? 0), 0);
const fr2TR = frLignes.reduce((s, l) => s + (l.case_2TR ?? 0), 0);
const fr2BH = frLignes.reduce((s, l) => s + (l.case_2BH ?? 0), 0);
const fr2CK = frLignes.reduce((s, l) => s + (l.case_2CK ?? 0), 0);
const fr2TY = frLignes.reduce((s, l) => s + (l.case_2TY ?? 0), 0);
// Totaux étrangers
const etrBA = p => r2(Object.values(p.mois ?? {}).reduce((s, v) => s + v, 0));
const applyFilter = items => filterMode === 'all' ? items : items.filter(i => (filterMode === 'auto' ? i.badge === BADGE_AUTO : i.badge === BADGE_DECL));
const totalEtrBA = r2(platEtr.reduce((s, p) => s + etrBA(p), 0));
const totalEtrIA = Math.round(totalEtrBA * rates.pfo);
// Breakdown 2TT : FR seulement
const bd2TT = applyFilter(frLignes.filter(l => l.case_2TT > 0).map(l => ({ nom: `${l.plateforme_nom}${fullName(l)}`, val: l.case_2TT, badge: BADGE_AUTO })));
const total2TT = bd2TT.reduce((s, i) => s + i.val, 0);
// Breakdown 2TR : FR (case_2TR) + étrangères
const bd2TR = applyFilter([
...frLignes.filter(l => l.case_2TR > 0).map(l => ({ nom: `${l.plateforme_nom}${fullName(l)}`, val: l.case_2TR, badge: BADGE_AUTO })),
...platEtr.filter(p => etrBA(p) > 0).map(p => ({ nom: p.nom, val: Math.round(etrBA(p)), badge: BADGE_DECL })),
]);
const total2TR = bd2TR.reduce((s, i) => s + i.val, 0);
// Breakdown 2BH : FR + étrangères
const bd2BH = applyFilter([
...frLignes.filter(l => l.case_2BH > 0).map(l => ({ nom: `${l.plateforme_nom}${fullName(l)}`, val: l.case_2BH, badge: BADGE_AUTO })),
...platEtr.filter(p => etrBA(p) > 0).map(p => ({ nom: p.nom, val: Math.round(etrBA(p)), badge: BADGE_DECL })),
]);
const total2BH = bd2BH.reduce((s, i) => s + i.val, 0);
// Breakdown 2CK : FR (PFNL retenu) + étrangères (acompte 2778-SD)
const bd2CK = applyFilter([
...frLignes.filter(l => l.case_2CK > 0).map(l => ({ nom: `${l.plateforme_nom}${fullName(l)}`, val: l.case_2CK, badge: BADGE_AUTO })),
...platEtr.filter(p => etrBA(p) > 0).map(p => ({ nom: p.nom, val: Math.round(etrBA(p) * rates.pfo), badge: BADGE_DECL })),
]);
const total2CK = bd2CK.reduce((s, i) => s + i.val, 0);
// Breakdown 2TY : FR seulement
const bd2TY = applyFilter(frLignes.filter(l => l.case_2TY > 0).map(l => ({ nom: `${l.plateforme_nom}${fullName(l)}`, val: l.case_2TY, badge: BADGE_AUTO })));
const total2TY = bd2TY.reduce((s, i) => s + i.val, 0);
return (
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<h4 style={{ margin: 0, fontWeight: 700 }}>Report annuel Déclaration 2042</h4>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Revenus {annee} à reporter sur la 2042 déposée en {Number(annee) + 1}</span>
<div style={{ flex: 1 }} />
<div style={{ display: 'flex', gap: 2, background: 'var(--surface-2)', borderRadius: 8, padding: 2, border: '1px solid var(--border)' }}>
{[['all','Tout'],['auto','Automatique'],['decl','À déclarer']].map(([val, label]) => (
<button key={val} onClick={() => setFilterMode(val)} style={{ padding: '4px 10px', borderRadius: 6, border: 'none', cursor: 'pointer', fontSize: 'var(--fs-xs)', fontWeight: 600, background: filterMode === val ? 'var(--primary)' : 'transparent', color: filterMode === val ? '#fff' : 'var(--text-muted)', transition: 'all .15s' }}>
{label}
</button>
))}
</div>
</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden', marginBottom: 14 }}>
<Case2042 code="2TT" label="Produits des prêts participatifs" note="Intérêts bruts — plateformes françaises (financement participatif, case KR)" value={total2TT || undefined} breakdown={bd2TT} />
<Case2042 code="2TR" label="Produits de placement à revenu fixe" note="Intérêts bruts — plateformes françaises (case AR) + étrangères (base imposable BA)" value={total2TR || undefined} breakdown={bd2TR} />
<Case2042 code="2BH" label="Produits pour lesquels les PS ont déjà été appliqués" note="Même montant que 2TT/2TR — neutralise la double imposition aux prélèvements sociaux" value={total2BH || undefined} breakdown={bd2BH} />
<Case2042 code="2CK" label="Crédit d'impôt — prélèvement forfaitaire déjà retenu" note={`FR : PFNL retenu à la source (12,8 %) · Étranger : acompte versé via 2778-SD (${(rates.pfo*100).toFixed(1)} %)`} value={total2CK || undefined} breakdown={bd2CK} />
<Case2042 code="2TY" label="Pertes en capital sur prêts participatifs" note="Capital non remboursé sur prêts en défaut — plateformes françaises" value={total2TY || undefined} breakdown={bd2TY} />
</div>
<div style={{ padding: '10px 14px', borderRadius: 8, background: 'rgba(99,102,241,0.05)', border: '1px solid rgba(99,102,241,0.15)', fontSize: 'var(--fs-xs)', lineHeight: 1.6, color: 'var(--text-muted)' }}>
<strong style={{ color: 'var(--text)', display: 'block', marginBottom: 4 }}>Synthèse fiscale</strong>
Les montants <BadgeTag badge={BADGE_AUTO} /> sont automatiquement reportés par la plateforme (IFU).
Les montants <BadgeTag badge={BADGE_DECL} /> nécessitent une déclaration mensuelle 2778-SD et un report manuel sur la 2042.
</div>
</div>
);
})();
return (
<div className="card">
<div style={{ display: 'flex', gap: 0, alignItems: 'flex-start' }}>
<nav style={{ width: 200, flexShrink: 0, marginRight: 20, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '8px 0', boxSizing: 'border-box' }}>
<div style={{ padding: '6px 12px 8px', fontSize: 'var(--fs-xs)', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.06em' }}>Sections</div>
<button className={`account-nav-item${view === 'matrice' ? ' active' : ''}`} onClick={() => setView('matrice')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 3h18v2H3V3zm0 7h12v2H3v-2zm0 7h18v2H3v-2z"/></svg>
Suivi mensuel
</button>
<button className={`account-nav-item${view === 'donnees' ? ' active' : ''}`} onClick={() => setView('donnees')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
Données 2042
</button>
</nav>
<div style={{ flex: 1, minWidth: 0 }}>
{view === 'matrice' && matriceView}
{view === 'donnees' && données2042View}
</div>
</div>
</div>
);
}
@@ -0,0 +1,791 @@
import { useEffect, useRef, useState } from 'react';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
const fmtEUR = n => {
if (!n && n !== 0) return '';
return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(n);
};
const fmtEURDec = n => {
if (!n && n !== 0) return '';
return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
};
/* ── Cellule du formulaire ── */
/* Layout : [code2561] [label + report 2042] [code2042 noir] [montant] */
function Cell({ label, code2561, code2042, value, filled, noReport }) {
return (
<div style={{
display: 'flex', alignItems: 'stretch',
border: '1px solid #aaa',
marginBottom: -1,
background: filled ? '#fffbe6' : '#fff',
}}>
{/* Case 1 — code 2561 (ex: KR) */}
<div style={{
width: 44, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRight: '1px solid #aaa',
background: '#f5f3ff',
fontSize: 11, fontWeight: 700, color: '#7c3aed',
}}>{code2561}</div>
{/* Case 2 — désignation + report */}
<div style={{
flex: 1, padding: '5px 8px',
fontSize: 11, color: '#222', lineHeight: 1.4,
borderRight: '1px solid #aaa',
}}>
<div>{label}</div>
<div style={{ fontSize: 10, color: noReport ? '#9a3412' : '#555', marginTop: 2 }}>
{noReport ? 'Sans report sur la déclaration 2042' : 'Montant à reporter sur votre déclaration 2042'}
</div>
</div>
{/* Case 3 — code 2042 (ex: 2TT) blanc sur noir */}
<div style={{
width: 48, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRight: '1px solid #aaa',
background: noReport ? '#78716c' : (filled ? '#111' : '#555'),
fontSize: 11, fontWeight: 700, color: '#fff',
}}>{code2042 ?? '—'}</div>
{/* Case 4 — montant */}
<div style={{
width: 90, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'flex-end',
padding: '0 10px',
fontSize: 12, fontWeight: filled ? 700 : 400,
color: filled ? '#14532d' : '#bbb',
background: filled ? '#f0fdf4' : '#fafafa',
}}>
{filled ? fmtEUR(value) + ' €' : '—'}
</div>
</div>
);
}
function SectionTitle({ children }) {
return (
<div style={{
background: '#5b21b6', color: '#fff',
WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact',
fontWeight: 700, fontSize: 11, textTransform: 'uppercase',
letterSpacing: '.04em', padding: '4px 8px',
marginTop: 10, marginBottom: 0,
}}>{children}</div>
);
}
function FieldRow({ label, code, value }) {
return (
<div style={{ display: 'flex', alignItems: 'stretch', border: '1px solid #aaa', marginBottom: -1 }}>
<div style={{ width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', textAlign: 'center', padding: '3px 4px', fontSize: 10, fontWeight: 600, color: '#7c3aed', borderRight: '1px solid #aaa', background: '#f5f3ff' }}>{code}</div>
<div style={{ width: 120, flexShrink: 0, display: 'flex', alignItems: 'center', padding: '3px 6px', fontSize: 11, color: '#222', borderRight: '1px solid #aaa' }}>{label}</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', padding: '3px 8px', fontSize: 11, color: value ? '#111' : 'transparent', background: '#fff' }}>{value || ''}</div>
</div>
);
}
/* ── Cellule éditable en place ── */
function EditableFieldRow({ label, code, value, onSave }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value ?? '');
const inputRef = useRef(null);
const startEdit = () => { setDraft(value ?? ''); setEditing(true); };
const confirm = () => { onSave(draft.trim() || null); setEditing(false); };
const onKey = e => { if (e.key === 'Enter') confirm(); if (e.key === 'Escape') setEditing(false); };
useEffect(() => { if (editing && inputRef.current) inputRef.current.focus(); }, [editing]);
return (
<div style={{ display: 'flex', alignItems: 'stretch', border: '1px solid #aaa', marginBottom: -1 }}>
<div style={{ width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3px 4px', fontSize: 10, fontWeight: 600, color: '#7c3aed', borderRight: '1px solid #aaa', background: '#f5f3ff' }}>{code}</div>
<div style={{ width: 120, flexShrink: 0, display: 'flex', alignItems: 'center', padding: '3px 6px', fontSize: 11, color: '#222', borderRight: '1px solid #aaa' }}>{label}</div>
<div
onClick={!editing ? startEdit : undefined}
style={{
flex: 1, display: 'flex', alignItems: 'center',
background: editing ? '#f5f3ff' : '#fff',
cursor: editing ? 'default' : 'text',
position: 'relative',
}}
>
{editing ? (
<>
<input
ref={inputRef}
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={onKey}
style={{
flex: 1, border: 'none', outline: 'none', background: 'transparent',
fontSize: 11, padding: '3px 8px', color: '#111',
}}
placeholder="Saisir…"
/>
<button
onClick={confirm}
title="Valider"
style={{
flexShrink: 0, border: 'none', background: 'none', cursor: 'pointer',
padding: '0 8px', color: '#7c3aed', fontSize: 14, fontWeight: 700,
}}
></button>
</>
) : (
<span style={{ padding: '3px 8px', fontSize: 11, color: value ? '#111' : '#bbb', fontStyle: value ? 'normal' : 'italic' }}>
{value || 'Cliquer pour saisir'}
</span>
)}
</div>
</div>
);
}
/* ── Formulaire d'une ligne (plateforme × investisseur) ── */
function Cerfa2561Form({ ligne, index, total }) {
const flatTax = ligne.domiciliation === 'FR' && ligne.fiscalite === 'flat_tax';
const use2TR = ligne.type_produit_fiscal === '2TR';
const hasPertes = ligne.case_2TY > 0;
const [taxDetails, setTaxDetails] = useState(null);
useEffect(() => {
api.get(`/plateforme-tax/${ligne.plateforme_id}/${ligne.annee}`)
.then(setTaxDetails)
.catch(() => setTaxDetails({ raison_sociale: ligne.plateforme_nom, siret_n: null, siret_n1: null }));
}, [ligne.plateforme_id, ligne.annee]); // eslint-disable-line
const save = (field) => (val) => {
if (!taxDetails) return;
const updated = { ...taxDetails, [field]: val };
setTaxDetails(updated);
api.patch(`/plateforme-tax/${taxDetails.id}`, { [field]: val });
};
return (
<div style={{
fontFamily: 'Arial, sans-serif',
maxWidth: 900, margin: '0 auto 40px',
border: '2px solid #5b21b6',
pageBreakAfter: index < total - 1 ? 'always' : 'auto',
colorScheme: 'light',
background: '#fff',
color: '#111',
}}>
{/* En-tête */}
<div style={{ background: '#5b21b6', color: '#fff', padding: '10px 14px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 700, letterSpacing: '.05em' }}>CERFA N°2561 N°11428*27</div>
<div style={{ fontSize: 11, marginTop: 2, opacity: .85 }}>Déclaration récapitulative des opérations sur valeurs mobilières et revenus de capitaux mobiliers</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 22, fontWeight: 700 }}>{ligne.annee}</div>
<div style={{ fontSize: 10, opacity: .8 }}>Simulation non officielle</div>
</div>
</div>
<div style={{ padding: 10 }}>
{/* Avertissement */}
<div style={{ background: '#fff8e1', border: '1px solid #f59e0b', borderRadius: 4, padding: '6px 10px', fontSize: 10, color: '#92400e', marginBottom: 10 }}>
Ce document est une simulation générée par votre outil de suivi. Il ne remplace pas le formulaire officiel émis par la plateforme.
Vérifiez les informations auprès de {ligne.plateforme_nom} avant toute déclaration.
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
{/* Colonne gauche */}
<div>
<SectionTitle>Désignation du payeur (plateforme)</SectionTitle>
<EditableFieldRow label="Raison sociale" code="ZM"
value={taxDetails?.raison_sociale ?? ligne.plateforme_nom}
onSave={save('raison_sociale')} />
<EditableFieldRow label={`N° SIRET au 31-12-${Number(ligne.annee) - 1}`} code="ZT"
value={taxDetails?.siret_n1 ?? null}
onSave={save('siret_n1')} />
<EditableFieldRow label={`N° SIRET au 31-12-${ligne.annee}`} code="ZS"
value={taxDetails?.siret_n ?? null}
onSave={save('siret_n')} />
<SectionTitle>Désignation du bénéficiaire (investisseur)</SectionTitle>
<FieldRow label="Nom de famille" code="ZC" value={ligne.investisseur_nom} />
<FieldRow label="Prénoms" code="ZD" value={ligne.investisseur_prenom} />
</div>
{/* Colonne droite — infos générales */}
<div>
<SectionTitle>Informations générales</SectionTitle>
<FieldRow label="Période de référence" code="AQ" value={`0101 1231`} />
<FieldRow label="Année fiscale" code="—" value={ligne.annee} />
<SectionTitle>Récapitulatif fiscal</SectionTitle>
<div style={{ border: '1px solid #aaa', padding: '6px 8px', background: '#f8faff', fontSize: 11 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3 }}>
<span>Intérêts bruts</span><strong>{fmtEURDec(ligne.interets_bruts)} </strong>
</div>
{flatTax && <>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3, color: '#dc2626' }}>
<span> Prélèvements sociaux</span><span>{fmtEURDec(ligne.prelev_sociaux)} </span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3, color: '#dc2626' }}>
<span> PFNL (12,8%)</span><span>{fmtEURDec(ligne.prelev_forfaitaire)} </span>
</div>
</>}
<div style={{ display: 'flex', justifyContent: 'space-between', borderTop: '1px solid #ccc', paddingTop: 3, fontWeight: 700 }}>
<span>Intérêts nets</span><span>{fmtEURDec(ligne.interets_nets)} </span>
</div>
<div style={{ marginTop: 4, fontSize: 10, color: '#666' }}>
{flatTax
? '✓ Plateforme française — PS et PFNL déjà prélevés à la source'
: '○ Plateforme étrangère — PS non prélevés, à régulariser'}
</div>
</div>
</div>
</div>
{/* Cases fiscales */}
<SectionTitle>Cases à remplir sur le formulaire 2561</SectionTitle>
{/* Section produits — 2TT ou 2TR selon paramétrage plateforme */}
{use2TR ? (
<>
<div style={{ background: '#ede9fe', padding: '4px 8px', fontSize: 10, fontWeight: 700, color: '#6d28d9', borderBottom: '1px solid #aaa', marginTop: 0 }}>
PRODUITS DE PLACEMENT À REVENU FIXE
</div>
<Cell
label="Gains — produits de placement à revenu fixe"
code2561="AR" code2042="2TR" value={ligne.case_2TR} filled={ligne.case_2TR > 0}
/>
</>
) : (
<>
<div style={{ background: '#ede9fe', padding: '4px 8px', fontSize: 10, fontWeight: 700, color: '#6d28d9', borderBottom: '1px solid #aaa', marginTop: 0 }}>
PRODUITS DES MINIBONS ET DES PRÊTS DANS LE CADRE DU FINANCEMENT PARTICIPATIF
</div>
<Cell
label="Produits des prêts dans le cadre du financement participatif"
code2561="KR" code2042="2TT" value={ligne.case_2TT} filled={ligne.case_2TT > 0}
/>
</>
)}
{use2TR ? (
<Cell
label="Pertes — produits de placement à revenu fixe"
code2561="AS" code2042={null} value={ligne.case_2TY} filled={ligne.case_2TY > 0}
noReport
/>
) : (
<Cell
label="Pertes sur prêts dans le cadre du financement participatif"
code2561="KS" code2042="2TY" value={ligne.case_2TY} filled={ligne.case_2TY > 0}
/>
)}
{/* Section 2BH — si PS ont été prélevés */}
{ligne.case_2BH > 0 && <>
<div style={{ background: '#ede9fe', padding: '4px 8px', fontSize: 10, fontWeight: 700, color: '#5b21b6', borderBottom: '1px solid #aaa', marginTop: 6 }}>
PRODUITS POUR LESQUELS LES PRÉLÈVEMENTS SOCIAUX ONT DÉJÀ ÉTÉ APPLIQUÉS
</div>
<Cell
label="Produits susceptibles d'ouvrir droit à CSG déductible en cas d'option pour le barème progressif"
code2561="DQ" code2042="2BH" value={ligne.case_2BH} filled={ligne.case_2BH > 0}
/>
</>}
{/* Section 2CK — flat-tax FR uniquement */}
{flatTax && ligne.case_2CK > 0 && <>
<div style={{ background: '#fef9c3', padding: '4px 8px', fontSize: 10, fontWeight: 700, color: '#854d0e', borderBottom: '1px solid #aaa', marginTop: 6 }}>
CRÉDIT D'IMPÔT PRÉLÈVEMENT
</div>
<Cell
label="Crédit d'impôt prélèvement PFNL déjà versé (12,8%)"
code2561="AD" code2042="2CK" value={ligne.case_2CK} filled={ligne.case_2CK > 0}
/>
</>}
{/* Nombre d'opérations — cliquable */}
<RembDetail ligne={ligne} />
</div>
</div>
);
}
/* ── Panneau remboursements dépliable ── */
function RembDetail({ ligne }) {
const [open, setOpen] = useState(false);
const [rows, setRows] = useState(null);
const [loading, setLoading] = useState(false);
const { activeView } = useInvestisseur();
const load = () => {
if (rows !== null) { setOpen(o => !o); return; }
setLoading(true);
const params = {
annee: ligne.annee,
plateforme_id: ligne.plateforme_id,
investisseur_id: ligne.investisseur_id,
...(activeView === 'all' ? { scope: 'all' } : {}),
};
api.get('/taxreport/cerfa2561/remboursements', params)
.then(data => { setRows(data); setOpen(true); })
.finally(() => setLoading(false));
};
const fmtDate = d => d ? d.slice(0, 10) : '—';
return (
<>
<div
onClick={load}
style={{
marginTop: 8, fontSize: 10, textAlign: 'right',
color: '#7c3aed', cursor: 'pointer', userSelect: 'none',
display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 4,
}}
>
{loading ? 'Chargement…' : (
<>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
{ligne.nb_remboursements} remboursement{ligne.nb_remboursements > 1 ? 's' : ''} pris en compte
</>
)}
</div>
{open && rows && (
<div className="no-print" style={{ marginTop: 6, border: '1px solid #ccc', borderRadius: 4, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 10 }}>
<thead>
<tr style={{ background: '#5b21b6' }}>
{[['Date','left'],['Projet','left'],['Capital','right'],['Intérêts bruts','right'],['Prélèv. sociaux','right'],['PFNL (2CK)','right'],['Intérêts nets','right']].map(([label, align]) => (
<th key={label} style={{ padding: '4px 8px', textAlign: align, fontWeight: 600, color: '#fff' }}>{label}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={r.id} style={{ background: i % 2 === 0 ? '#fff' : '#f8faff', borderBottom: '1px solid #e5e7eb' }}>
<td style={{ padding: '3px 8px', whiteSpace: 'nowrap' }}>{fmtDate(r.date_remb)}</td>
<td style={{ padding: '3px 8px', color: '#444' }}>{r.nom_projet}</td>
<td style={{ padding: '3px 8px', textAlign: 'right' }}>{fmtEURDec(r.capital)} €</td>
<td style={{ padding: '3px 8px', textAlign: 'right', fontWeight: 600 }}>{fmtEURDec(r.interets_bruts)} €</td>
<td style={{ padding: '3px 8px', textAlign: 'right', color: '#dc2626' }}>{r.prelev_sociaux ? `${fmtEURDec(r.prelev_sociaux)} €` : '—'}</td>
<td style={{ padding: '3px 8px', textAlign: 'right', color: '#dc2626' }}>{r.prelev_forfaitaire ? `${fmtEURDec(r.prelev_forfaitaire)} €` : '—'}</td>
<td style={{ padding: '3px 8px', textAlign: 'right', fontWeight: 600, color: '#14532d' }}>{fmtEURDec(r.interets_nets)} €</td>
</tr>
))}
</tbody>
<tfoot>
<tr style={{ background: '#f5f3ff', fontWeight: 700, borderTop: '2px solid #5b21b6' }}>
<td colSpan={3} style={{ padding: '4px 8px', fontSize: 10 }}>Total</td>
<td style={{ padding: '4px 8px', textAlign: 'right' }}>{fmtEURDec(rows.reduce((s, r) => s + r.interets_bruts, 0))} €</td>
<td style={{ padding: '4px 8px', textAlign: 'right', color: '#dc2626' }}>{fmtEURDec(rows.reduce((s, r) => s + r.prelev_sociaux, 0))} €</td>
<td style={{ padding: '4px 8px', textAlign: 'right', color: '#dc2626' }}>{fmtEURDec(rows.reduce((s, r) => s + r.prelev_forfaitaire, 0))} €</td>
<td style={{ padding: '4px 8px', textAlign: 'right', color: '#14532d' }}>{fmtEURDec(rows.reduce((s, r) => s + r.interets_nets, 0))} €</td>
</tr>
</tfoot>
</table>
</div>
)}
</>
);
}
const MOIS_LABELS = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
const MOIS_NUMS = ['01','02','03','04','05','06','07','08','09','10','11','12'];
const r2 = v => Math.round((v ?? 0) * 100) / 100;
/* ── Report 2042 pour plateformes françaises ── */
function Report2042Block2561({ lignes }) {
const frLignes = (lignes ?? []).filter(l => l.domiciliation === 'FR');
if (frLignes.length === 0) return (
<div className="card"><p className="text-muted" style={{ margin: 0 }}>Aucune plateforme française avec des remboursements.</p></div>
);
const total2TT = frLignes.reduce((s, l) => s + (l.case_2TT ?? 0), 0);
const total2TR = frLignes.reduce((s, l) => s + (l.case_2TR ?? 0), 0);
const total2BH = frLignes.reduce((s, l) => s + (l.case_2BH ?? 0), 0);
const total2CK = frLignes.reduce((s, l) => s + (l.case_2CK ?? 0), 0);
const total2TY = frLignes.reduce((s, l) => s + (l.case_2TY ?? 0), 0);
const fmt = v => Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
const fullName = l => { const n = l.investisseur_nom ?? ''; const p = l.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); };
const Case2042 = ({ code, label, note, value, breakdown }) => {
if (!value) return null;
return (
<div style={{ borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'grid', gridTemplateColumns: '90px 1fr auto', gap: '0 12px', alignItems: 'center', padding: '10px 14px' }}>
<div style={{ fontFamily: 'monospace', fontWeight: 800, fontSize: 'var(--fs-base)', color: 'var(--primary)', background: 'rgba(99,102,241,0.07)', borderRadius: 6, padding: '2px 6px', textAlign: 'center', letterSpacing: '.04em' }}>{code}</div>
<div>
<div style={{ fontSize: 'var(--fs-sm)', fontWeight: 600 }}>{label}</div>
{note && <div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 2 }}>{note}</div>}
</div>
<div style={{ fontWeight: 800, fontSize: 'var(--fs-lg)', whiteSpace: 'nowrap', color: 'var(--text)' }}>{fmt(value)} €</div>
</div>
{breakdown && breakdown.length > 0 && (
<div style={{ padding: '0 14px 10px 118px', display: 'flex', flexDirection: 'column', gap: 3 }}>
{breakdown.map(p => (
<div key={p.nom} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
<span>└ {p.nom}</span>
<span style={{ fontWeight: 500 }}>{fmt(p.val)} €</span>
</div>
))}
</div>
)}
</div>
);
};
const annee = frLignes[0]?.annee;
return (
<div className="card">
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 14 }}>
<h4 style={{ margin: 0, fontWeight: 700 }}>Report annuel — Déclaration 2042</h4>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Revenus {annee} à reporter sur la 2042 déposée en {Number(annee) + 1}</span>
</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden', marginBottom: 14 }}>
<Case2042
code="2TT"
label="Produits des prêts participatifs (financement participatif)"
note="Intérêts bruts plateformes françaises avec case KR (2TT)"
value={total2TT}
breakdown={frLignes.filter(l => l.case_2TT > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2TT }))}
/>
<Case2042
code="2TR"
label="Produits de placement à revenu fixe"
note="Intérêts bruts plateformes françaises avec case AR (2TR)"
value={total2TR}
breakdown={frLignes.filter(l => l.case_2TR > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2TR }))}
/>
<Case2042
code="2BH"
label="Produits pour lesquels les prélèvements sociaux ont déjà été appliqués"
note="Même montant que 2TT/2TR évite la double imposition aux prélèvements sociaux"
value={total2BH}
breakdown={frLignes.filter(l => l.case_2BH > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2BH }))}
/>
<Case2042
code="2CK"
label="Crédit d'impôt — PFNL déjà versé (12,8 %)"
note="Prélèvement forfaitaire non libératoire déjà retenu à la source — s'impute sur l'IR définitif"
value={total2CK}
breakdown={frLignes.filter(l => l.case_2CK > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2CK }))}
/>
<Case2042
code="2TY"
label="Pertes en capital sur prêts participatifs"
note="Capital non remboursé sur prêts en défaut ou clôturés"
value={total2TY}
breakdown={frLignes.filter(l => l.case_2TY > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2TY }))}
/>
</div>
<div style={{ padding: '10px 14px', borderRadius: 8, background: 'rgba(99,102,241,0.05)', border: '1px solid rgba(99,102,241,0.15)', fontSize: 'var(--fs-xs)', lineHeight: 1.6, color: 'var(--text-muted)' }}>
<strong style={{ color: 'var(--text)', display: 'block', marginBottom: 4 }}>Comment fonctionne le PFU sur les plateformes françaises ?</strong>
Les plateformes françaises soumises à la Flat Tax retiennent à la source les prélèvements sociaux (17,2 %) et l'impôt forfaitaire (12,8 %).
Le montant 2CK correspond à l'acompte IR déjà versé — il s'impute sur l'impôt définitif calculé lors de votre 2042.
Le montant 2BH est déclaré en sus de 2TT/2TR pour neutraliser les prélèvements sociaux déjà prélevés.
</div>
</div>
);
}
/* ── Composant principal ── */
export default function Cerfa2561Preview({ annee, activeView, onClose, inline = false, expanded = false, onToggleExpand }) {
const { activeId } = useInvestisseur();
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [filterPlat, setFilterPlat] = useState('all');
const [view, setView] = useState('matrice');
const contentRef = useRef(null);
const handlePrint = () => {
const content = contentRef.current;
if (!content) return;
const platName = data?.lignes?.find(l => `${l.plateforme_id}_${l.investisseur_id}` === filterPlat)?.plateforme_nom ?? 'Plateforme';
const printWin = window.open('', '_blank', 'width=900,height=700');
printWin.document.write(`<!DOCTYPE html><html><head>
<meta charset="utf-8"/>
<title>CERFA 2561 — ${annee} — ${platName}</title>
<style>
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
body { margin: 0; padding: 20px; font-family: Arial, sans-serif; background: white; color: #111; }
@media print { body { padding: 0; } }
.no-print { display: none !important; }
</style>
</head><body>${content.innerHTML}</body></html>`);
printWin.document.close();
printWin.focus();
setTimeout(() => { printWin.print(); printWin.close(); }, 400);
};
useEffect(() => {
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
api.get('/taxreport/cerfa2561', { annee, ...scopeParams })
.then(d => {
setData(d);
const firstFr = d.lignes.find(l => l.domiciliation === 'FR') ?? d.lignes[0];
if (firstFr) setFilterPlat(`${firstFr.plateforme_id}_${firstFr.investisseur_id}`);
})
.finally(() => setLoading(false));
}, [annee, activeView]); // eslint-disable-line
const frLignes = (data?.lignes ?? []).filter(l => l.domiciliation === 'FR');
/* ── Tableau mensuel ── */
const matriceView = (
<div style={{ overflowX: 'auto' }}>
{loading && <div className="card text-muted">Chargement…</div>}
{!loading && frLignes.length === 0 && (
<div className="card"><p className="text-muted" style={{ margin: 0 }}>Aucune plateforme française avec des remboursements pour {annee}.</p></div>
)}
{!loading && frLignes.length > 0 && (
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, padding: '14px 14px 10px' }}>
<h4 style={{ margin: 0, fontWeight: 700 }}>Suivi mensuel des intérêts bruts</h4>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Revenus {annee} — plateformes françaises</span>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--fs-xs)' }}>
<thead>
<tr style={{ background: 'var(--surface-2)', borderBottom: '1px solid var(--border)' }}>
<th style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 600, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Plateforme — Détenteur</th>
{MOIS_LABELS.map(m => (
<th key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 600, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>{m}</th>
))}
<th style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 600, color: 'var(--text-muted)' }}>Total</th>
</tr>
</thead>
<tbody>
{frLignes.map((l, idx) => {
const total = r2(Object.values(l.mois ?? {}).reduce((s, m) => s + (m.interets_bruts ?? 0), 0));
return (
<tr key={`${l.plateforme_id}_${l.investisseur_id}`} style={{ borderBottom: '1px solid var(--border)', background: 'transparent' }}>
<td style={{ padding: '6px 12px', fontWeight: 500, whiteSpace: 'nowrap' }}>
{l.plateforme_nom}
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginLeft: 6 }}>— {(() => { const n = l.investisseur_nom ?? ''; const p = l.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); })()}</span>
</td>
{MOIS_NUMS.map(m => {
const v = l.mois?.[m]?.interets_bruts ?? 0;
return (
<td key={m} style={{ padding: '6px 8px', textAlign: 'right', color: v > 0 ? 'var(--text)' : 'var(--text-muted)' }}>
{v > 0 ? v.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ' : ''}
</td>
);
})}
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700 }}>
{total > 0 ? total.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ' : ''}
</td>
</tr>
);
})}
</tbody>
<tfoot>
{/* Total intérêt brut */}
<tr style={{ borderTop: '2px solid var(--border)', background: 'var(--surface-2)' }}>
<td style={{ padding: '6px 12px', fontWeight: 700 }}>Total intérêt brut</td>
{MOIS_NUMS.map(m => {
const v = r2(frLignes.reduce((s, l) => s + (l.mois?.[m]?.interets_bruts ?? 0), 0));
return (
<td key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 700, color: v > 0 ? 'var(--text)' : 'var(--text-muted)' }}>
{v > 0 ? v.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : ''}
</td>
);
})}
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700 }}>
{r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + (m.interets_bruts ?? 0), 0)), 0)).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €
</td>
</tr>
{/* Taux prélevé */}
<tr style={{ background: 'transparent' }}>
<td style={{ padding: '4px 12px', color: 'var(--text-muted)', fontSize: 'var(--fs-xs)' }}>
Taux total prélevé ({(() => {
const totalBrut = r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + (m.interets_bruts ?? 0), 0)), 0));
const totalPrelev = r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + ((m.prelev_sociaux ?? 0) + (m.prelev_forfaitaire ?? 0)), 0)), 0));
return totalBrut > 0 ? (totalPrelev / totalBrut * 100).toFixed(1) : '';
})()} %)
</td>
{MOIS_NUMS.map(m => {
const brut = r2(frLignes.reduce((s, l) => s + (l.mois?.[m]?.interets_bruts ?? 0), 0));
const prelev = r2(frLignes.reduce((s, l) => s + ((l.mois?.[m]?.prelev_sociaux ?? 0) + (l.mois?.[m]?.prelev_forfaitaire ?? 0)), 0));
const taux = brut > 0 ? (prelev / brut * 100).toFixed(1) + ' %' : '';
return (
<td key={m} style={{ padding: '4px 8px', textAlign: 'right', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
{taux}
</td>
);
})}
<td style={{ padding: '4px 12px', textAlign: 'right', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
{(() => {
const totalBrut = r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + (m.interets_bruts ?? 0), 0)), 0));
const totalPrelev = r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + ((m.prelev_sociaux ?? 0) + (m.prelev_forfaitaire ?? 0)), 0)), 0));
return totalBrut > 0 ? (totalPrelev / totalBrut * 100).toFixed(1) + ' %' : '';
})()}
</td>
</tr>
{/* Total intérêt net */}
<tr style={{ background: 'rgba(22,163,74,0.06)', borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '6px 12px', fontWeight: 700, color: 'var(--success)' }}>Total intérêt net</td>
{MOIS_NUMS.map(m => {
const net = r2(frLignes.reduce((s, l) => {
const mo = l.mois?.[m];
return s + ((mo?.interets_bruts ?? 0) - (mo?.prelev_sociaux ?? 0) - (mo?.prelev_forfaitaire ?? 0));
}, 0));
return (
<td key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 700, color: net > 0 ? 'var(--success)' : 'var(--text-muted)' }}>
{net > 0 ? net.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : ''}
</td>
);
})}
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700, color: 'var(--success)' }}>
{r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + ((m.interets_bruts ?? 0) - (m.prelev_sociaux ?? 0) - (m.prelev_forfaitaire ?? 0)), 0)), 0)).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €
</td>
</tr>
{/* Note de bas de tableau */}
<tr style={{ background: 'var(--surface-2)' }}>
<td colSpan={14} style={{ padding: '8px 12px', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', borderTop: '1px solid var(--border)' }}>
Taux {annee} : PFO 12,8 % + CSG 10,6 % + CRDS 0,5 % + Solidarité 7,5 % = 31,4 % &nbsp;·&nbsp; Prélèvements effectués à la source par les plateformes &nbsp;·&nbsp; Report automatique sur la déclaration de revenus 2042
</td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
);
/* ── Barre outils (vue cerfa) ── */
const toolbar = (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
{data && frLignes.length > 0 && (
<select
value={filterPlat}
onChange={e => setFilterPlat(e.target.value)}
style={{ fontSize: 'var(--fs-sm)', padding: '4px 8px', width: 240 }}
>
{frLignes.map(l => (
<option key={`${l.plateforme_id}_${l.investisseur_id}`} value={`${l.plateforme_id}_${l.investisseur_id}`}>
{l.plateforme_nom} — {(() => { const n = l.investisseur_nom ?? ''; const p = l.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); })()}
</option>
))}
</select>
)}
<div style={{ flex: 1 }} />
{inline && onToggleExpand && (
<button className="icon-btn" onClick={() => onToggleExpand()} title={expanded ? 'Réduire' : 'Agrandir'}>
{expanded ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/>
<line x1="10" y1="14" x2="3" y2="21"/><line x1="21" y1="3" x2="14" y2="10"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
)}
</button>
)}
<button className="icon-btn" onClick={handlePrint} disabled={loading || !frLignes.length} title="Imprimer / Enregistrer en PDF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 6 2 18 2 18 9"/>
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/>
<rect x="6" y="14" width="12" height="8"/>
</svg>
</button>
{!inline && (
<button className="icon-btn" onClick={onClose} title="Fermer">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
)}
</div>
);
/* ── Vue cerfa ── */
const cerfaView = (
<div ref={contentRef}>
{toolbar}
{loading && <div style={{ textAlign: 'center', color: '#666', paddingTop: 20 }}>Chargement…</div>}
{!loading && frLignes.length === 0 && (
<div style={{ textAlign: 'center', color: '#666', paddingTop: 20 }}>Aucune plateforme française avec des remboursements pour {annee}.</div>
)}
{!loading && data?.lignes
?.filter(l => `${l.plateforme_id}_${l.investisseur_id}` === filterPlat)
.map((ligne, i, arr) => (
<Cerfa2561Form key={`${ligne.plateforme_id}_${ligne.investisseur_id}`} ligne={ligne} index={i} total={arr.length} />
))}
</div>
);
if (inline) {
return (
<div className="card">
<div style={{ display: 'flex', gap: 0, alignItems: 'flex-start' }}>
{/* Sidebar nav */}
<nav style={{ width: 200, flexShrink: 0, marginRight: 20, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '8px 0', boxSizing: 'border-box' }}>
<div style={{ padding: '6px 12px 8px', fontSize: 'var(--fs-xs)', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.06em' }}>
Sections
</div>
<button className={`account-nav-item${view === 'matrice' ? ' active' : ''}`} onClick={() => setView('matrice')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 3h18v2H3V3zm0 7h12v2H3v-2zm0 7h18v2H3v-2z"/>
</svg>
Suivi mensuel
</button>
<button className={`account-nav-item${view === 'cerfa' ? ' active' : ''}`} onClick={() => setView('cerfa')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
Données 2561
</button>
<button className={`account-nav-item${view === 'report2042' ? ' active' : ''}`} onClick={() => setView('report2042')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
Report 2042
</button>
</nav>
{/* Contenu */}
<div style={{ flex: 1, minWidth: 0 }}>
{view === 'matrice' && matriceView}
{view === 'cerfa' && cerfaView}
{view === 'report2042' && <Report2042Block2561 lignes={data?.lignes} />}
</div>
</div>
</div>
);
}
/* ── Vue modale (non-inline) ── */
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.6)', display: 'flex', flexDirection: 'column' }}>
<div className="no-print" style={{ background: 'var(--surface)', borderBottom: '1px solid var(--border)', padding: '8px 16px', flexShrink: 0 }}>
{toolbar}
</div>
<div ref={contentRef} style={{ flex: 1, overflowY: 'auto', padding: '24px 20px', background: '#e5e7eb' }}>
{loading && <div style={{ textAlign: 'center', color: '#666', paddingTop: 40 }}>Chargement…</div>}
{!loading && data?.lignes?.length === 0 && (
<div style={{ textAlign: 'center', color: '#666', paddingTop: 40 }}>Aucun remboursement trouvé pour {annee}.</div>
)}
{!loading && data?.lignes
?.filter(l => `${l.plateforme_id}_${l.investisseur_id}` === filterPlat)
.map((ligne, i, arr) => (
<Cerfa2561Form key={`${ligne.plateforme_id}_${ligne.investisseur_id}`} ligne={ligne} index={i} total={arr.length} />
))}
</div>
</div>
);
}
@@ -0,0 +1,621 @@
import { useEffect, useState } from 'react';
import { api } from '../api.js';
const MOIS_LABELS = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
const MOIS_NUMS = ['01','02','03','04','05','06','07','08','09','10','11','12'];
// Taux par défaut (2026+) — remplacés par les données de /api/pfu
const DEFAULT_RATES = { pfo: 0.128, csg: 0.106, crds: 0.005, solidarite: 0.075 };
function getRatesForYear(annee, pfuList) {
const yr = Number(annee);
// Chercher l'année exacte, puis l'année précédente la plus proche
const sorted = [...pfuList].sort((a, b) => b.annee - a.annee);
const match = sorted.find(r => r.annee <= yr);
if (!match) return DEFAULT_RATES;
return {
pfo: (match.impot_revenu ?? 12.8) / 100,
csg: (match.csg ?? 9.2) / 100,
crds: (match.crds ?? 0.5) / 100,
solidarite: (match.solidarite ?? 7.5) / 100,
};
}
const r = v => Math.round((v ?? 0) * 100) / 100;
const fmtEUR = v => {
if (v == null || v === 0) return <span style={{ color: 'var(--text-muted)' }}>- </span>;
return `${Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`;
};
const fmtInt = v => {
if (v == null || v === 0) return <span style={{ color: 'var(--text-muted)' }}>-</span>;
return `${v}`;
};
/* ── Simulation cases CERFA pour un montant brut ── */
function computeCases(ba, rates) {
const R = rates ?? DEFAULT_RATES;
const BA = Math.round(ba);
const IA = Math.round(BA * R.pfo);
const PQ = Math.round(BA * R.csg);
const PV = Math.round(BA * R.crds);
const PF1 = PQ + PV;
const PG1 = Math.round(BA * R.solidarite);
const PU = PF1;
const PK = PG1;
const QR = IA + PU + PK;
const totalTax = R.pfo + R.csg + R.crds + R.solidarite;
return { BA, IA, PQ, PV, PF1, PG1, PU, PK, QR, totalTax, pfo: R.pfo, csgRate: R.csg, crdsRate: R.crds, solidRate: R.solidarite };
}
/* ── Composant principal ── */
export default function Cerfa2778Preview({ annee, activeView }) {
const LS_KEY = 'cl_2778_excluded_plats';
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [pfuList, setPfuList] = useState([]);
const [excluded, setExcluded] = useState(() => {
try { return new Set(JSON.parse(localStorage.getItem(LS_KEY)) ?? []); }
catch { return new Set(); }
});
const [selectedMois, setSelectedMois] = useState(null);
const [view, setView] = useState('matrice'); // 'matrice' | 'cerfa'
useEffect(() => {
setLoading(true);
setData(null);
setSelectedMois(null);
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
Promise.all([
api.get('/taxreport/2778', { annee, ...scopeParams }),
pfuList.length === 0 ? api.get('/pfu') : Promise.resolve(null),
]).then(([d, pfu]) => {
setData(d);
if (pfu) setPfuList(pfu);
}).then(([d]) => {
// Auto-sélection du dernier mois avec données si vue cerfa active
if (d?.plateformes) {
const totaux = ['01','02','03','04','05','06','07','08','09','10','11','12'].map(m => {
const stored = JSON.parse(localStorage.getItem('cl_2778_excluded_plats') ?? '[]');
const excl = new Set(stored);
return d.plateformes.filter(p => !excl.has(p.id)).reduce((s, p) => s + (p.mois[m] ?? 0), 0);
});
const last = totaux.reduce((idx, val, i) => val > 0 ? i : idx, null);
if (last !== null) setSelectedMois(last);
}
}).finally(() => setLoading(false));
}, [annee, activeView]); // eslint-disable-line
if (loading || !data) return <div className="card text-muted">Chargement</div>;
const plateformes = data.plateformes;
const rates = getRatesForYear(annee, pfuList);
const TOTAL_TAX = rates.pfo + rates.csg + rates.crds + rates.solidarite;
if (plateformes.length === 0) {
return (
<div className="card">
<p className="text-muted" style={{ margin: 0 }}>
Aucun remboursement de plateforme étrangère pour {annee}.
</p>
</div>
);
}
/* ── Auto-sélection du dernier mois avec données ── */
// (calculé après le rendu initial)
/* ── Totaux mensuels des plateformes incluses ── */
const totauxMois = MOIS_NUMS.map(m => {
let sum = 0;
for (const plat of plateformes) {
if (!excluded.has(plat.id)) sum += plat.mois[m] ?? 0;
}
return r(sum);
});
const lastMoisWithData = totauxMois.reduce((last, val, i) => val > 0 ? i : last, null);
/* ── Données du mois sélectionné pour simulation ── */
const moisData = selectedMois !== null ? (() => {
const mNum = MOIS_NUMS[selectedMois];
const ba = totauxMois[selectedMois];
const detail = plateformes
.filter(p => !excluded.has(p.id) && (p.mois[mNum] ?? 0) > 0)
.map(p => ({ nom: p.nom, montant: p.mois[mNum] }));
return { ba, detail, cases: computeCases(ba, rates) };
})() : null;
/* ── Toggle plateforme + persistance localStorage ── */
const togglePlat = (id) => {
setExcluded(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
localStorage.setItem(LS_KEY, JSON.stringify([...next]));
return next;
});
};
const cellStyle = {
textAlign: 'right',
padding: '6px 10px',
fontSize: 'var(--fs-sm)',
whiteSpace: 'nowrap',
borderBottom: '1px solid var(--border)',
};
const headStyle = {
textAlign: 'center',
padding: '6px 10px',
fontSize: 'var(--fs-xs)',
fontWeight: 600,
color: 'var(--text-muted)',
background: 'var(--surface-2)',
borderBottom: '1px solid var(--border)',
whiteSpace: 'nowrap',
};
return (
<div className="card">
{/* ── Navigation sidebar ── */}
<div style={{ display: 'flex', gap: 0, alignItems: 'flex-start' }}>
<nav style={{
width: 200, flexShrink: 0, marginRight: 20,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 10, padding: '8px 0', boxSizing: 'border-box',
}}>
<div style={{ padding: '6px 12px 8px', fontSize: 'var(--fs-xs)', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.06em' }}>
Sections
</div>
<button
className={`account-nav-item${view === 'matrice' ? ' active' : ''}`}
onClick={() => { setView('matrice'); }}
>
<svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='1.8' strokeLinecap='round' strokeLinejoin='round'>
<path d='M3 3h18v2H3V3zm0 7h12v2H3v-2zm0 7h18v2H3v-2z'/>
</svg>
Suivi mensuel
</button>
<button
className={`account-nav-item${view === 'cerfa' ? ' active' : ''}`}
onClick={() => { setView('cerfa'); if (selectedMois === null) setSelectedMois(lastMoisWithData); }}
>
<svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='1.8' strokeLinecap='round' strokeLinejoin='round'>
<path d='M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'/>
</svg>
Données 2778-SD
</button>
<button
className={`account-nav-item${view === 'report2042' ? ' active' : ''}`}
onClick={() => { setView('report2042'); }}
>
<svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='1.8' strokeLinecap='round' strokeLinejoin='round'>
<path d='M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z'/>
</svg>
Report 2042
</button>
<div style={{ margin: '10px 12px 6px', borderTop: '1px solid var(--border)' }} />
<div style={{ padding: '4px 12px', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', lineHeight: 1.5 }}>
Plateformes non-françaises<br/>Base = intérêts bruts avant retenue locale
</div>
</nav>
<div style={{ flex: 1, minWidth: 0 }}>
{/* ── Vue Matrice ── */}
{view === 'matrice' && (
<>
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, padding: '14px 14px 10px' }}>
<h4 style={{ margin: 0, fontWeight: 700 }}>Suivi mensuel des intérêts bruts</h4>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Revenus {annee} plateformes étrangères</span>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 900 }}>
<thead>
<tr>
<th style={{ ...headStyle, textAlign: 'left', minWidth: 180, position: 'sticky', left: 0, background: 'var(--surface-2)' }}>
Plateforme Détenteur
</th>
{MOIS_LABELS.map((m, i) => (
<th key={m} style={headStyle}>{m}</th>
))}
<th style={{ ...headStyle, background: 'var(--surface-2)' }}>Total</th>
</tr>
</thead>
<tbody>
{plateformes.map(plat => {
const isExcluded = excluded.has(plat.id);
const total = Object.values(plat.mois).reduce((a, b) => a + b, 0);
return (
<tr key={plat.id} style={{ opacity: isExcluded ? 0.4 : 1, transition: 'opacity .15s' }}>
<td style={{
...cellStyle, textAlign: 'left',
position: 'sticky', left: 0,
background: 'var(--surface)',
fontWeight: 500,
}}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none', fontSize: 'var(--fs-sm)', textTransform: 'none', letterSpacing: 'normal', color: 'var(--text)', fontWeight: 400, marginBottom: 0 }}>
<input
type="checkbox"
checked={!isExcluded}
onChange={() => togglePlat(plat.id)}
style={{ accentColor: 'var(--primary)', width: 14, height: 14, flexShrink: 0 }}
/>
<span style={{ fontWeight: 500 }}>
{plat.nom}
{(plat.investisseur_nom || plat.investisseur_prenom) && (
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginLeft: 6, fontWeight: 400 }}>
{(() => { const n = plat.investisseur_nom ?? ''; const p = plat.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); })()}
</span>
)}
</span>
</label>
</td>
{MOIS_NUMS.map(m => (
<td key={m} style={cellStyle}>{fmtEUR(plat.mois[m] ?? null)}</td>
))}
<td style={{ ...cellStyle, fontWeight: 600 }}>{fmtEUR(r(total))}</td>
</tr>
);
})}
{/* ── Séparateur ── */}
<tr><td colSpan={14} style={{ height: 4, background: 'var(--surface-2)' }} /></tr>
{/* ── Total brut mensuel ── */}
<tr style={{ background: 'var(--surface-2)' }}>
<td style={{ ...cellStyle, textAlign: 'left', fontWeight: 600, position: 'sticky', left: 0, background: 'var(--surface-2)' }}>
Total intérêt brut
</td>
{totauxMois.map((t, i) => (
<td key={i} style={{ ...cellStyle, fontWeight: 600 }}>{fmtEUR(t || null)}</td>
))}
<td style={{ ...cellStyle, fontWeight: 700 }}>
{fmtEUR(r(totauxMois.reduce((a, b) => a + b, 0)))}
</td>
</tr>
{/* ── Taux Flat Tax ── */}
<tr>
<td style={{ ...cellStyle, textAlign: 'left', color: 'var(--text-muted)', position: 'sticky', left: 0, background: 'var(--surface)' }}>
Taux total prélevé ({(TOTAL_TAX * 100).toFixed(1)} %)
</td>
{MOIS_NUMS.map((_, i) => (
<td key={i} style={{ ...cellStyle, color: 'var(--text-muted)' }}>
{totauxMois[i] > 0 ? `${(TOTAL_TAX * 100).toFixed(2).replace('.', ',')} %` : '—'}
</td>
))}
<td style={{ ...cellStyle, color: 'var(--text-muted)' }}>{`${(TOTAL_TAX * 100).toFixed(2).replace('.', ',')} %`}</td>
</tr>
{/* ── Total Flat Tax ── */}
<tr style={{ background: 'rgba(239,68,68,0.06)' }}>
<td style={{ ...cellStyle, textAlign: 'left', fontWeight: 600, color: 'var(--danger)', position: 'sticky', left: 0, background: 'rgba(239,68,68,0.06)' }}>
Montant total à payer (QR)
</td>
{totauxMois.map((t, i) => {
const { QR } = computeCases(t, rates);
return (
<td key={i} style={{ ...cellStyle, fontWeight: 600, color: t > 0 ? 'var(--danger)' : undefined }}>
{t > 0 ? fmtInt(QR) : <span style={{ color: 'var(--text-muted)' }}>-</span>}
</td>
);
})}
<td style={{ ...cellStyle, fontWeight: 700, color: 'var(--danger)' }}>
{fmtInt(computeCases(r(totauxMois.reduce((a, b) => a + b, 0)), rates).QR)}
</td>
</tr>
{/* ── Intérêt net ── */}
<tr style={{ background: 'rgba(16,185,129,0.05)' }}>
<td style={{ ...cellStyle, textAlign: 'left', fontWeight: 600, color: 'var(--success)', position: 'sticky', left: 0, background: 'rgba(16,185,129,0.05)' }}>
Total intérêt net
</td>
{totauxMois.map((t, i) => (
<td key={i} style={{ ...cellStyle, fontWeight: 600, color: t > 0 ? 'var(--success)' : undefined }}>
{fmtEUR(t > 0 ? r(t * (1 - TOTAL_TAX)) : null)}
</td>
))}
<td style={{ ...cellStyle, fontWeight: 700, color: 'var(--success)' }}>
{fmtEUR(r(totauxMois.reduce((a, b) => a + b, 0) * (1 - TOTAL_TAX)))}
</td>
</tr>
</tbody>
</table>
<div style={{ padding: '10px 16px', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', borderTop: '1px solid var(--border)', display: 'flex', gap: 24, flexWrap: 'wrap' }}>
<span>Taux {annee} : PFO {(rates.pfo*100).toFixed(1)} % + CSG {(rates.csg*100).toFixed(1)} % + CRDS {(rates.crds*100).toFixed(1)} % + Solidarité {(rates.solidarite*100).toFixed(1)} % = {(TOTAL_TAX*100).toFixed(1)} %</span>
<span>·</span>
<span> Déclaration et paiement dus dans les <strong>15 premiers jours du mois suivant</strong> l'encaissement</span>
<span>·</span>
<span>Base imposable : intérêts bruts <em>après</em> déduction de l'impôt prélevé à la source à l'étranger, <em>avant</em> déduction retenue "directive épargne"</span>
</div>
</div>
</>
)}
{/* ── Vue Report 2042 ── */}
{view === 'report2042' && (
<Report2042Block annee={annee} totauxMois={totauxMois} rates={rates} plateformes={plateformes} excluded={excluded} />
)}
{/* ── Vue Données 2778-SD ── */}
{view === 'cerfa' && (
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'flex-start' }}>
{/* Sélecteur de mois */}
<div className="card" style={{ minWidth: 200, flexShrink: 0 }}>
<h4 style={{ margin: '0 0 12px', fontSize: 'var(--fs-sm)', fontWeight: 600 }}>Mois</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{MOIS_LABELS.map((label, i) => {
const ba = totauxMois[i];
const isActive = selectedMois === i;
return (
<button
key={i}
onClick={() => setSelectedMois(i)}
style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '7px 12px', borderRadius: 6, border: 'none',
background: isActive ? 'var(--primary)' : (ba > 0 ? 'var(--surface-2)' : 'transparent'),
color: isActive ? '#fff' : (ba > 0 ? 'var(--text)' : 'var(--text-muted)'),
cursor: ba > 0 ? 'pointer' : 'default',
fontWeight: isActive ? 600 : 400,
fontSize: 'var(--fs-sm)',
opacity: ba > 0 ? 1 : 0.5,
}}
disabled={ba === 0}
>
<span>{label}</span>
{ba > 0 && <span style={{ fontSize: 'var(--fs-xs)' }}>{Math.round(ba)} €</span>}
</button>
);
})}
</div>
</div>
{/* CERFA 2778-SD simulé */}
{moisData && (
<div style={{ flex: 1, minWidth: 400 }}>
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }}>
<h4 style={{ margin: 0, fontWeight: 700 }}>
Formulaire 2778-SD — {MOIS_LABELS[selectedMois]} {annee}
</h4>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Simulation indicative</span>
</div>
{/* Détail des plateformes incluses */}
{moisData.detail.length > 1 && (
<div style={{ marginBottom: 14, padding: '10px 12px', background: 'var(--surface-2)', borderRadius: 8 }}>
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginBottom: 6, fontWeight: 500, textTransform: 'uppercase', letterSpacing: '.05em' }}>
Plateformes incluses
</div>
{moisData.detail.map(d => (
<div key={d.nom} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
<span>{d.nom}</span>
<span style={{ fontWeight: 500 }}>{r(d.montant).toLocaleString('fr-FR', { minimumFractionDigits: 2 })} €</span>
</div>
))}
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--fs-sm)', fontWeight: 700, borderTop: '1px solid var(--border)', marginTop: 6, paddingTop: 6 }}>
<span>Total</span>
<span>{r(moisData.ba).toLocaleString('fr-FR', { minimumFractionDigits: 2 })} €</span>
</div>
</div>
)}
<CerfaBlock cases={moisData.cases} ba_exact={moisData.ba} mois={selectedMois} annee={annee} />
</div>
</div>
)}
{selectedMois === null && (
<div className="card" style={{ flex: 1, color: 'var(--text-muted)', textAlign: 'center', padding: 40 }}>
Sélectionnez un mois pour simuler le formulaire.
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
/* ── Report annuel 2042 ── */
function Report2042Block({ annee, totauxMois, rates, plateformes, excluded }) {
const totalBA = r(totauxMois.reduce((a, b) => a + b, 0));
if (totalBA === 0) return null;
const totalIA = computeCases(totalBA, rates).IA;
// Détail par plateforme incluse (annuel)
const platDetail = plateformes
.filter(p => !excluded.has(p.id))
.map(p => {
const ba = r(Object.values(p.mois).reduce((a, b) => a + b, 0));
return { nom: p.nom, ba, ia: computeCases(ba, rates).IA };
})
.filter(p => p.ba > 0);
const Case2042 = ({ code, label, note, value, breakdown }) => (
<div style={{ borderBottom: '1px solid var(--border)' }}>
<div style={{
display: 'grid', gridTemplateColumns: '90px 1fr auto',
gap: '0 12px', alignItems: 'center', padding: '10px 14px',
}}>
<div style={{
fontFamily: 'monospace', fontWeight: 800, fontSize: 'var(--fs-base)',
color: 'var(--primary)', background: 'rgba(99,102,241,0.07)',
borderRadius: 6, padding: '2px 6px', textAlign: 'center', letterSpacing: '.04em',
}}>{code}</div>
<div>
<div style={{ fontSize: 'var(--fs-sm)', fontWeight: 600 }}>{label}</div>
{note && <div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 2 }}>{note}</div>}
</div>
<div style={{ fontWeight: 800, fontSize: 'var(--fs-lg)', whiteSpace: 'nowrap', color: 'var(--text)' }}>
{Number(value).toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} €
</div>
</div>
{breakdown && breakdown.length > 1 && (
<div style={{ padding: '0 14px 10px 118px', display: 'flex', flexDirection: 'column', gap: 3 }}>
{breakdown.map(p => (
<div key={p.nom} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
<span>└ {p.nom}</span>
<span style={{ fontWeight: 500 }}>
{Number(p.val).toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} €
</span>
</div>
))}
</div>
)}
</div>
);
return (
<div className="card">
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 14 }}>
<h4 style={{ margin: 0, fontWeight: 700 }}>Report annuel — Déclaration 2042</h4>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Revenus {annee} à reporter sur la 2042 déposée en {Number(annee) + 1}</span>
</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden', marginBottom: 14 }}>
<Case2042
code="2TR"
label="Produits de placement à revenu fixe de source étrangère"
note="Intérêts bruts totaux encaissés sur l'année (somme des cases BA)"
value={Math.round(totalBA)}
breakdown={platDetail.map(p => ({ nom: p.nom, val: Math.round(p.ba) }))}
/>
<Case2042
code="2BH"
label="Produits soumis aux prélèvements sociaux (idem 2TR)"
note="Même montant que 2TR évite la double imposition aux prélèvements sociaux"
value={Math.round(totalBA)}
breakdown={platDetail.map(p => ({ nom: p.nom, val: Math.round(p.ba) }))}
/>
<Case2042
code="2CK"
label="Prélèvement forfaitaire non libératoire déjà versé (acompte)"
note="Somme des cases IA de vos 2778-SD s'impute sur l'IR définitif, l'excédent est restitué"
value={totalIA}
breakdown={platDetail.map(p => ({ nom: p.nom, val: p.ia }))}
/>
</div>
<div style={{
padding: '10px 14px', borderRadius: 8,
background: 'rgba(99,102,241,0.05)', border: '1px solid rgba(99,102,241,0.15)',
fontSize: 'var(--fs-xs)', lineHeight: 1.6, color: 'var(--text-muted)',
}}>
<strong style={{ color: 'var(--text)', display: 'block', marginBottom: 4 }}>Comment fonctionne le PFO ?</strong>
Le prélèvement forfaitaire obligatoire (PFO, case 2CK) versé via la 2778-SD est un <em>acompte</em> sur l'impôt sur le revenu, non libératoire.
L'imposition définitive est calculée lors de votre 2042 : par défaut au taux de <strong>{(rates.pfo * 100).toFixed(1)} %</strong> (Flat Tax),
ou sur option expresse au barème progressif. L'acompte déjà versé (2CK) s'impute sur l'IR définitif tout excédent vous est restitué.
Le montant 2BH est déclaré en sus de 2TR uniquement pour neutraliser les prélèvements sociaux déjà prélevés via la 2778-SD.
</div>
</div>
);
}
/* ── Bloc cases CERFA ── */
function CerfaBlock({ cases, ba_exact, mois, annee }) {
const { BA, IA, PQ, PV, PF1, PG1, PU, PK, QR } = cases;
const Row = ({ label, code, value, highlight, note, caseSup, base, taux, noUnit }) => (
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 90px 90px 90px 90px 90px',
gap: '0 8px',
alignItems: 'baseline',
padding: '8px 12px',
borderBottom: '1px solid var(--border)',
background: highlight ? 'rgba(239,68,68,0.05)' : 'transparent',
}}>
<div style={{ fontSize: 'var(--fs-sm)', whiteSpace: 'pre-line' }}>
{label}
{note && <span style={{ display: 'block', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 1 }}>{note}</span>}
</div>
<div style={{ fontSize: 'var(--fs-sm)', textAlign: 'center', color: 'var(--text-muted)' }}>{base != null ? `${base}` : ''}</div>
<div style={{ fontSize: 'var(--fs-xs)', textAlign: 'center', color: 'var(--text-muted)' }}>{taux ?? ''}</div>
<div style={{ fontFamily: 'monospace', fontWeight: 700, fontSize: 'var(--fs-sm)', color: 'var(--primary)', textAlign: 'center' }}>{code}</div>
<div style={{
fontWeight: highlight ? 700 : 600,
textAlign: 'center',
color: highlight ? 'var(--danger)' : 'var(--text)',
fontSize: highlight ? 'var(--fs-base)' : 'var(--fs-sm)',
}}>{value != null ? (noUnit ? value : `${value}`) : ''}</div>
<div style={{ fontFamily: 'monospace', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', alignSelf: 'center', textAlign: 'center' }}>{caseSup ?? ''}</div>
</div>
);
const SectionHeader = ({ title }) => (
<div style={{
padding: '6px 12px',
background: 'var(--surface-2)',
fontSize: 'var(--fs-xs)', fontWeight: 600,
color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.06em',
}}>{title}</div>
);
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
{/* En-tête */}
<div style={{
display: 'grid', gridTemplateColumns: '1fr 90px 90px 90px 90px 90px', gap: '0 8px',
padding: '6px 12px', background: 'var(--surface-2)',
fontSize: 'var(--fs-xs)', fontWeight: 600, color: 'var(--text-muted)',
borderBottom: '1px solid var(--border)',
}}>
<span>Libellé</span><span style={{ textAlign: 'center' }}>Base imposable (BA)</span><span style={{ textAlign: 'center' }}>Taux</span><span style={{ textAlign: 'center' }}>Case</span><span style={{ textAlign: 'center' }}>Montant</span><span></span>
</div>
<SectionHeader title="Page de garde (page 1)" />
<Row label="Mois concerné par la déclaration" note="Mois au cours duquel les revenus ont été encaissés" code="" value={mois != null ? `${MOIS_LABELS[mois]} ${annee ?? new Date().getFullYear()}` : '—'} noUnit />
<Row label="Paiement" note="Somme à payer — reporter le montant déterminé en dernière page, case QR" code="QR" value={QR} highlight />
<SectionHeader title="Prélèvement forfaitaire obligatoire non libératoire (page 2)" />
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)' }}>
<span style={{ fontSize: 'var(--fs-sm)', fontWeight: 600 }}>Produits de placement à revenu fixe de source étrangère soumis au prélèvement forfaitaire obligatoire non libératoire</span>
</div>
<Row
label={`Intérêts et produits des obligations, créances, dépôts, cautionnements, comptes courants, fonds communs de créances, bons de caisse.\nPrélèvement forfaitaire non libératoire (BA × ${(cases.pfo*100||12.8).toFixed(1)}%)` }
code="IA"
value={IA}
base={BA}
taux={`${(cases.pfo*100||12.8).toFixed(1)} %`}
note={ba_exact !== BA ? `Montant exact: ${r(ba_exact).toLocaleString('fr-FR', { minimumFractionDigits: 2 })} € → arrondi à ${BA} €` : null}
/>
<Row label="Total prélèvement forfaitaire non libératoire (IA + …)" code="" value={IA} caseSup="A422" />
<SectionHeader title="Prélèvements sociaux (page 3)" />
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)' }}>
<span style={{ fontSize: 'var(--fs-sm)', fontWeight: 600 }}>Produits de placements à revenu fixe et produits afférents aux versements déductibles faisant l'objet d'un retrait en capital des PER de source étrangère</span>
</div>
<Row label={`CSG (BA × ${(cases.csgRate*100||10.6).toFixed(1)}%)`} code="PQ" value={PQ} base={BA} taux={`${(cases.csgRate*100||10.6).toFixed(1)} %`} />
<Row label={`CRDS (BA × ${(cases.crdsRate*100||0.5).toFixed(1)}%)`} code="PV" value={PV} base={BA} taux={`${(cases.crdsRate*100||0.5).toFixed(1)} %`} />
<Row label="Total prélèvements sociaux hors solidarité (PQ + PV) → PF1" code="PF1" value={PF1} />
<Row label={`Prélèvement de solidarité (BA × ${(cases.solidRate*100||7.5).toFixed(1)}%)`} code="PG1" value={PG1} base={BA} taux={`${(cases.solidRate*100||7.5).toFixed(1)} %`} />
<SectionHeader title="Totaux à reporter (page 4)" />
<Row label="Total prélèvements sociaux hors solidarité (PF1 + …)" code="PU" value={PU} caseSup="0701" />
<Row label="Total prélèvement de solidarité (PG1 + …)" code="PK" value={PK} caseSup="A392" />
<div style={{
display: 'grid', gridTemplateColumns: '1fr 90px 90px 90px 90px 90px', gap: '0 8px',
padding: '12px 12px',
background: 'linear-gradient(135deg, rgba(239,68,68,0.08), rgba(239,68,68,0.04))',
borderTop: '2px solid var(--danger)',
}}>
<div style={{ fontWeight: 700, fontSize: 'var(--fs-sm)' }}>
MONTANT TOTAL À PAYER (IA + PU + PK)
<div style={{ fontSize: 'var(--fs-xs)', fontWeight: 400, color: 'var(--text-muted)', marginTop: 2 }}>
À reporter en première page du formulaire
</div>
</div>
<div></div><div></div>
<div style={{ fontFamily: 'monospace', fontWeight: 700, color: 'var(--danger)', fontSize: 'var(--fs-base)', alignSelf: 'center', textAlign: 'center' }}>QR</div>
<div style={{ fontWeight: 800, fontSize: '1.4rem', textAlign: 'center', color: 'var(--danger)', alignSelf: 'center' }}>{QR}</div>
<div></div>
</div>
</div>
);
}
+231
View File
@@ -0,0 +1,231 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { fmtEUR } from '../utils/format.js';
const ICONS_BASE = '/api/icons-files/';
/* Colonnes du tableau — dans l'ordre de la 2561 */
const COLS = [
{ key: 'case_2TT', code: '2TT', label: 'Produits prêts participatifs', color: '#7c3aed' },
{ key: 'case_2TR', code: '2TR', label: 'Produits placement revenu fixe', color: '#7c3aed' },
{ key: 'case_2TY', code: '2TY / AS',label: 'Pertes en capital', color: '#dc2626', danger: true },
{ key: 'case_2BH', code: '2BH', label: 'Base CSG/CRDS (PS prélevés)', color: '#1d4ed8' },
{ key: 'case_2CK', code: '2CK', label: "Crédit d'impôt prélèvement", color: '#059669' },
];
export default function CerfaRecapTable({ annee, activeView }) {
const [lignes, setLignes] = useState(null);
const [loading, setLoading] = useState(true);
const [libIcons, setLibIcons] = useState({});
const [showFr, setShowFr] = useState(true);
const [showWw, setShowWw] = useState(true);
/* Icônes bibliothèque */
useEffect(() => {
api.get('/icons').then(rows => {
const m = {};
rows.forEach(r => { m[r.name] = r.filename; });
setLibIcons(m);
}).catch(() => {});
}, []);
useEffect(() => {
setLoading(true);
setLignes(null);
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
api.get('/taxreport/cerfa2561', { annee, ...scopeParams })
.then(d => setLignes(d.lignes))
.finally(() => setLoading(false));
}, [annee, activeView]); // eslint-disable-line
/* Filtrage FR / WW */
const filtered = useMemo(() => {
if (!lignes) return [];
return lignes.filter(l => {
const isFr = l.domiciliation === 'FR';
return isFr ? showFr : showWw;
});
}, [lignes, showFr, showWw]);
const totals = useMemo(() => (
Object.fromEntries(
COLS.map(c => [c.key, filtered.reduce((s, l) => s + (l[c.key] || 0), 0)])
)
), [filtered]);
/* Composant icône bibliothèque */
const AppIcon = ({ name, size = 66, active }) => {
const filename = libIcons[name];
if (filename) return (
<img
src={ICONS_BASE + filename}
className="app-lib-icon app-lib-icon-no-invert"
width={size} height={size}
aria-hidden="true"
style={{ opacity: active ? 1 : 0.25, display: 'block', transition: 'opacity .15s' }}
/>
);
return (
<span style={{
width: size, height: size, display: 'block', borderRadius: 8,
background: 'var(--text-muted)', opacity: active ? 0.55 : 0.15,
transition: 'opacity .15s',
}} />
);
};
/* Détection présence de chaque type dans les données */
const hasFr = lignes?.some(l => l.domiciliation === 'FR') ?? false;
const hasWw = lignes?.some(l => l.domiciliation !== 'FR') ?? false;
/* Détecter si plusieurs détenteurs distincts */
const multiDetenteur =
new Set((lignes ?? []).map(l => l.investisseur_id).filter(v => v != null)).size > 1;
if (loading) {
return (
<div className="solde-chart-wrap" style={{ padding: '24px', marginBottom: 0 }}>
<span className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>Chargement</span>
</div>
);
}
if (!lignes || lignes.length === 0) {
return (
<div className="solde-chart-wrap" style={{ padding: '24px', marginBottom: 0 }}>
<span className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>Aucune donnée pour {annee}.</span>
</div>
);
}
return (
<div className="solde-chart-wrap" style={{ padding: '20px 24px 16px', marginBottom: 0 }}>
{/* Header */}
<div className="solde-chart-header" style={{ marginBottom: 12 }}>
<div className="solde-chart-info">
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', marginBottom: 2 }}>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
background: 'rgba(124,58,237,0.12)', borderRadius: 5, padding: '3px 8px',
}}>
<span style={{ width: 7, height: 7, borderRadius: 2, background: '#7c3aed', flexShrink: 0 }} />
<span style={{ fontSize: 13, color: '#7c3aed', fontWeight: 600 }}>Cases 2561</span>
</span>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>· {annee}</span>
</div>
<div className="solde-chart-value" style={{ fontSize: '1.4rem' }}>
{fmtEUR(totals.case_2TT + totals.case_2TR)}
</div>
</div>
{/* Boutons filtre FR / WW */}
<div className="solde-chart-controls">
{hasFr && (
<button
title={showFr ? 'Plateformes françaises incluses' : 'Afficher les plateformes françaises'}
onClick={() => setShowFr(v => !v)}
style={{
background: showFr ? 'rgba(59,130,246,0.12)' : 'none',
border: '1px solid ' + (showFr ? 'rgba(59,130,246,0.5)' : 'transparent'),
borderRadius: 10, padding: '5px 7px', cursor: 'pointer',
display: 'flex', alignItems: 'center',
transition: 'background .15s, border-color .15s',
}}
>
<div style={{ background: '#fff', borderRadius: 7, lineHeight: 0 }}>
<AppIcon name="plateforme-fr" size={44} active={showFr} />
</div>
</button>
)}
{hasWw && (
<button
title={showWw ? 'Plateformes étrangères incluses' : 'Afficher les plateformes étrangères'}
onClick={() => setShowWw(v => !v)}
style={{
background: showWw ? 'rgba(16,185,129,0.12)' : 'none',
border: '1px solid ' + (showWw ? 'rgba(16,185,129,0.5)' : 'transparent'),
borderRadius: 10, padding: '5px 7px', cursor: 'pointer',
display: 'flex', alignItems: 'center',
transition: 'background .15s, border-color .15s',
}}
>
<div style={{ background: '#fff', borderRadius: 7, lineHeight: 0 }}>
<AppIcon name="plateforme-ww" size={44} active={showWw} />
</div>
</button>
)}
</div>
</div>
{filtered.length === 0 ? (
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>
Aucune plateforme à afficher activez au moins un filtre.
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table className="tip-table">
<thead>
<tr>
<th className="tip-th-name tip-th-name-amber">Plateforme</th>
{COLS.map(c => (
<th key={c.key} className="tip-th-month" style={{ minWidth: 110 }}>
<span style={{ display: 'block', fontWeight: 800, letterSpacing: '.03em' }}>{c.code}</span>
<span style={{ display: 'block', fontSize: 10, fontWeight: 400, opacity: .85, marginTop: 1 }}>{c.label}</span>
</th>
))}
</tr>
</thead>
<tbody>
{filtered.map(l => (
<tr key={`${l.plateforme_id}_${l.investisseur_id}`} className="tip-row-plat">
<td className="tip-td-name">
{l.plateforme_nom}
{multiDetenteur && l.investisseur_prenom && (
<span style={{ marginLeft: 6, fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>
{l.investisseur_prenom}
</span>
)}
</td>
{COLS.map(c => {
const v = l[c.key] || 0;
return (
<td
key={c.key}
className="tip-td-num"
style={c.danger && v > 0 ? { color: '#dc2626', fontWeight: 600 } : undefined}
>
{v > 0 ? fmtEUR(v) : <span className="tip-dash">---</span>}
</td>
);
})}
</tr>
))}
</tbody>
<tfoot>
<tr className="tip-footer-total">
<td className="tip-td-name">Total {annee}</td>
{COLS.map(c => {
const v = totals[c.key] || 0;
return (
<td
key={c.key}
className="tip-td-total"
style={c.danger && v > 0 ? { color: '#dc2626' } : undefined}
>
{v > 0 ? fmtEUR(v) : <span className="tip-dash">---</span>}
</td>
);
})}
</tr>
</tfoot>
</table>
</div>
)}
<p style={{ margin: '10px 0 0', fontSize: 11, color: 'var(--text-muted)' }}>
Montants indicatifs. Référez-vous à votre IFU et à la notice 2041-GFI avant toute déclaration.
</p>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import Modal from './Modal.jsx';
/**
* Modale de confirmation générique.
* Props :
* open — booléen
* title — titre de la modale (défaut : "Confirmer la suppression")
* message — texte explicatif
* confirmLabel — libellé du bouton de confirmation (défaut : "Supprimer")
* onConfirm — callback appelé au clic "Confirmer"
* onCancel — callback appelé au clic "Annuler" ou ✕
*/
export default function ConfirmModal({
open,
title = 'Confirmer la suppression',
message,
confirmLabel = 'Supprimer',
onConfirm,
onCancel,
}) {
return (
<Modal
open={open}
title={title}
onClose={onCancel}
width={440}
footer={
<>
<button className="ghost" type="button" onClick={onCancel}>Annuler</button>
<button className="danger" type="button" onClick={onConfirm}>{confirmLabel}</button>
</>
}
>
<p style={{ margin: '8px 0 4px', color: 'var(--text)', lineHeight: 1.5 }}>{message}</p>
</Modal>
);
}
+383
View File
@@ -0,0 +1,383 @@
import { useState, useRef, useEffect } from 'react';
// Composant drapeau via flag-icons CSS (cdnjs)
// Usage : <FlagIcon code="FR" />
const FlagIcon = ({ code, size = 20 }) => (
<span
className={`fi fi-${(code || 'fr').toLowerCase()}`}
style={{ width: size, height: Math.round(size * 0.75), display: 'inline-block', flexShrink: 0, borderRadius: 2 }}
/>
);
// Liste complète ISO 3166-1 alpha-2 — noms en français
const COUNTRIES = [
{ code: 'AF', name: 'Afghanistan' },
{ code: 'ZA', name: 'Afrique du Sud' },
{ code: 'AL', name: 'Albanie' },
{ code: 'DZ', name: 'Algérie' },
{ code: 'DE', name: 'Allemagne' },
{ code: 'AD', name: 'Andorre' },
{ code: 'AO', name: 'Angola' },
{ code: 'AG', name: 'Antigua-et-Barbuda' },
{ code: 'SA', name: 'Arabie saoudite' },
{ code: 'AR', name: 'Argentine' },
{ code: 'AM', name: 'Arménie' },
{ code: 'AU', name: 'Australie' },
{ code: 'AT', name: 'Autriche' },
{ code: 'AZ', name: 'Azerbaïdjan' },
{ code: 'BS', name: 'Bahamas' },
{ code: 'BH', name: 'Bahreïn' },
{ code: 'BD', name: 'Bangladesh' },
{ code: 'BB', name: 'Barbade' },
{ code: 'BY', name: 'Biélorussie' },
{ code: 'BE', name: 'Belgique' },
{ code: 'BZ', name: 'Belize' },
{ code: 'BJ', name: 'Bénin' },
{ code: 'BT', name: 'Bhoutan' },
{ code: 'BO', name: 'Bolivie' },
{ code: 'BA', name: 'Bosnie-Herzégovine' },
{ code: 'BW', name: 'Botswana' },
{ code: 'BR', name: 'Brésil' },
{ code: 'BN', name: 'Brunéi' },
{ code: 'BG', name: 'Bulgarie' },
{ code: 'BF', name: 'Burkina Faso' },
{ code: 'BI', name: 'Burundi' },
{ code: 'CV', name: 'Cap-Vert' },
{ code: 'KH', name: 'Cambodge' },
{ code: 'CM', name: 'Cameroun' },
{ code: 'CA', name: 'Canada' },
{ code: 'CF', name: 'République centrafricaine' },
{ code: 'CL', name: 'Chili' },
{ code: 'CN', name: 'Chine' },
{ code: 'CY', name: 'Chypre' },
{ code: 'CO', name: 'Colombie' },
{ code: 'KM', name: 'Comores' },
{ code: 'CG', name: 'Congo' },
{ code: 'CD', name: 'Congo (RDC)' },
{ code: 'KP', name: 'Corée du Nord' },
{ code: 'KR', name: 'Corée du Sud' },
{ code: 'CR', name: 'Costa Rica' },
{ code: 'HR', name: 'Croatie' },
{ code: 'CU', name: 'Cuba' },
{ code: 'DK', name: 'Danemark' },
{ code: 'DJ', name: 'Djibouti' },
{ code: 'DO', name: 'République dominicaine' },
{ code: 'DM', name: 'Dominique' },
{ code: 'EG', name: 'Égypte' },
{ code: 'SV', name: 'Salvador' },
{ code: 'AE', name: 'Émirats arabes unis' },
{ code: 'EC', name: 'Équateur' },
{ code: 'ER', name: 'Érythrée' },
{ code: 'ES', name: 'Espagne' },
{ code: 'EE', name: 'Estonie' },
{ code: 'SZ', name: 'Eswatini' },
{ code: 'ET', name: 'Éthiopie' },
{ code: 'FJ', name: 'Fidji' },
{ code: 'FI', name: 'Finlande' },
{ code: 'FR', name: 'France' },
{ code: 'GA', name: 'Gabon' },
{ code: 'GM', name: 'Gambie' },
{ code: 'GE', name: 'Géorgie' },
{ code: 'GH', name: 'Ghana' },
{ code: 'GR', name: 'Grèce' },
{ code: 'GD', name: 'Grenade' },
{ code: 'GT', name: 'Guatemala' },
{ code: 'GN', name: 'Guinée' },
{ code: 'GW', name: 'Guinée-Bissau' },
{ code: 'GQ', name: 'Guinée équatoriale' },
{ code: 'GY', name: 'Guyana' },
{ code: 'HT', name: 'Haïti' },
{ code: 'HN', name: 'Honduras' },
{ code: 'HU', name: 'Hongrie' },
{ code: 'IN', name: 'Inde' },
{ code: 'ID', name: 'Indonésie' },
{ code: 'IQ', name: 'Irak' },
{ code: 'IR', name: 'Iran' },
{ code: 'IE', name: 'Irlande' },
{ code: 'IS', name: 'Islande' },
{ code: 'IL', name: 'Israël' },
{ code: 'IT', name: 'Italie' },
{ code: 'JM', name: 'Jamaïque' },
{ code: 'JP', name: 'Japon' },
{ code: 'JO', name: 'Jordanie' },
{ code: 'KZ', name: 'Kazakhstan' },
{ code: 'KE', name: 'Kenya' },
{ code: 'KG', name: 'Kirghizistan' },
{ code: 'KI', name: 'Kiribati' },
{ code: 'KW', name: 'Koweït' },
{ code: 'LA', name: 'Laos' },
{ code: 'LS', name: 'Lesotho' },
{ code: 'LV', name: 'Lettonie' },
{ code: 'LB', name: 'Liban' },
{ code: 'LR', name: 'Liberia' },
{ code: 'LY', name: 'Libye' },
{ code: 'LI', name: 'Liechtenstein' },
{ code: 'LT', name: 'Lituanie' },
{ code: 'LU', name: 'Luxembourg' },
{ code: 'MK', name: 'Macédoine du Nord' },
{ code: 'MG', name: 'Madagascar' },
{ code: 'MY', name: 'Malaisie' },
{ code: 'MW', name: 'Malawi' },
{ code: 'MV', name: 'Maldives' },
{ code: 'ML', name: 'Mali' },
{ code: 'MT', name: 'Malte' },
{ code: 'MA', name: 'Maroc' },
{ code: 'MH', name: 'Îles Marshall' },
{ code: 'MU', name: 'Maurice' },
{ code: 'MR', name: 'Mauritanie' },
{ code: 'MX', name: 'Mexique' },
{ code: 'FM', name: 'Micronésie' },
{ code: 'MD', name: 'Moldavie' },
{ code: 'MC', name: 'Monaco' },
{ code: 'MN', name: 'Mongolie' },
{ code: 'ME', name: 'Monténégro' },
{ code: 'MZ', name: 'Mozambique' },
{ code: 'MM', name: 'Myanmar' },
{ code: 'NA', name: 'Namibie' },
{ code: 'NR', name: 'Nauru' },
{ code: 'NP', name: 'Népal' },
{ code: 'NI', name: 'Nicaragua' },
{ code: 'NE', name: 'Niger' },
{ code: 'NG', name: 'Nigeria' },
{ code: 'NO', name: 'Norvège' },
{ code: 'NZ', name: 'Nouvelle-Zélande' },
{ code: 'OM', name: 'Oman' },
{ code: 'UG', name: 'Ouganda' },
{ code: 'UZ', name: 'Ouzbékistan' },
{ code: 'PK', name: 'Pakistan' },
{ code: 'PW', name: 'Palaos' },
{ code: 'PA', name: 'Panama' },
{ code: 'PG', name: 'Papouasie-Nouvelle-Guinée' },
{ code: 'PY', name: 'Paraguay' },
{ code: 'NL', name: 'Pays-Bas' },
{ code: 'PE', name: 'Pérou' },
{ code: 'PH', name: 'Philippines' },
{ code: 'PL', name: 'Pologne' },
{ code: 'PT', name: 'Portugal' },
{ code: 'QA', name: 'Qatar' },
{ code: 'RO', name: 'Roumanie' },
{ code: 'GB', name: 'Royaume-Uni' },
{ code: 'RU', name: 'Russie' },
{ code: 'RW', name: 'Rwanda' },
{ code: 'KN', name: 'Saint-Kitts-et-Nevis' },
{ code: 'SM', name: 'Saint-Marin' },
{ code: 'VC', name: 'Saint-Vincent-et-les-Grenadines' },
{ code: 'LC', name: 'Sainte-Lucie' },
{ code: 'SB', name: 'Îles Salomon' },
{ code: 'WS', name: 'Samoa' },
{ code: 'ST', name: 'Sao Tomé-et-Principe' },
{ code: 'SN', name: 'Sénégal' },
{ code: 'RS', name: 'Serbie' },
{ code: 'SC', name: 'Seychelles' },
{ code: 'SL', name: 'Sierra Leone' },
{ code: 'SG', name: 'Singapour' },
{ code: 'SK', name: 'Slovaquie' },
{ code: 'SI', name: 'Slovénie' },
{ code: 'SO', name: 'Somalie' },
{ code: 'SD', name: 'Soudan' },
{ code: 'SS', name: 'Soudan du Sud' },
{ code: 'LK', name: 'Sri Lanka' },
{ code: 'SE', name: 'Suède' },
{ code: 'CH', name: 'Suisse' },
{ code: 'SR', name: 'Suriname' },
{ code: 'SY', name: 'Syrie' },
{ code: 'TJ', name: 'Tadjikistan' },
{ code: 'TZ', name: 'Tanzanie' },
{ code: 'TD', name: 'Tchad' },
{ code: 'CZ', name: 'Tchéquie' },
{ code: 'TH', name: 'Thaïlande' },
{ code: 'TL', name: 'Timor oriental' },
{ code: 'TG', name: 'Togo' },
{ code: 'TO', name: 'Tonga' },
{ code: 'TT', name: 'Trinité-et-Tobago' },
{ code: 'TN', name: 'Tunisie' },
{ code: 'TM', name: 'Turkménistan' },
{ code: 'TR', name: 'Turquie' },
{ code: 'TV', name: 'Tuvalu' },
{ code: 'UA', name: 'Ukraine' },
{ code: 'UY', name: 'Uruguay' },
{ code: 'VU', name: 'Vanuatu' },
{ code: 'VE', name: 'Venezuela' },
{ code: 'VN', name: 'Viêt Nam' },
{ code: 'YE', name: 'Yémen' },
{ code: 'ZM', name: 'Zambie' },
{ code: 'ZW', name: 'Zimbabwe' },
{ code: 'US', name: 'États-Unis' },
].sort((a, b) => a.name.localeCompare(b.name, 'fr'));
export { COUNTRIES, FlagIcon };
/**
* CountrySelect — combobox pays avec drapeaux emoji et recherche par frappe
*
* Props :
* value : code ISO 2 lettres (ex. 'FR')
* onChange : (code) => void
* required : bool (optionnel)
*/
export default function CountrySelect({ value, onChange, required, showCode }) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const containerRef = useRef(null);
const inputRef = useRef(null);
const listRef = useRef(null);
const selected = COUNTRIES.find(c => c.code === value);
const filtered = search
? COUNTRIES.filter(c =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
c.code.toLowerCase().includes(search.toLowerCase())
)
: COUNTRIES;
// Ferme le dropdown si clic en dehors
useEffect(() => {
const handler = (e) => {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setOpen(false);
setSearch('');
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const select = (code) => {
onChange(code);
setOpen(false);
setSearch('');
};
const handleOpen = () => {
setOpen(true);
setSearch('');
setTimeout(() => inputRef.current?.focus(), 0);
};
// Navigation clavier dans la liste
const handleKeyDown = (e) => {
if (e.key === 'Escape') { setOpen(false); setSearch(''); }
};
return (
<div ref={containerRef} style={{ position: 'relative' }}>
{/* Trigger */}
<button
type="button"
onClick={open ? () => { setOpen(false); setSearch(''); } : handleOpen}
style={{
width: '100%',
textAlign: 'left',
padding: '0 10px',
height: 36,
background: 'var(--surface-2, var(--surface))',
border: open ? '1px solid var(--primary)' : '1px solid var(--border)',
borderRadius: 6,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 14,
color: 'var(--text)',
outline: open ? '2px solid var(--primary)' : 'none',
outlineOffset: 1,
}}
>
{selected ? (
<>
<FlagIcon code={selected.code} />
<span>{selected.name}</span>
{showCode && <span style={{ color: 'var(--text-muted)', fontSize: 12 }}>({selected.code})</span>}
</>
) : (
<span style={{ color: 'var(--text-muted)' }}>Sélectionner un pays</span>
)}
<svg style={{ marginLeft: 'auto', flexShrink: 0, opacity: 0.5, transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="6 9 12 15 18 9"/></svg>
</button>
{/* Dropdown */}
{open && (
<div
style={{
position: 'absolute',
bottom: 'calc(100% + 4px)',
left: 0,
right: 0,
zIndex: 500,
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
boxShadow: '0 8px 24px rgba(0,0,0,0.15)',
overflow: 'hidden',
}}
>
{/* Champ de recherche */}
<div style={{ padding: '8px 8px 4px' }}>
<input
ref={inputRef}
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Rechercher un pays…"
style={{
width: '100%',
boxSizing: 'border-box',
padding: '6px 10px',
border: '1px solid var(--border)',
borderRadius: 6,
background: 'var(--surface-2, var(--surface))',
color: 'var(--text)',
fontSize: 13,
outline: 'none',
}}
/>
</div>
{/* Liste des pays */}
<div
ref={listRef}
style={{
maxHeight: 128,
overflowY: 'auto',
padding: '4px 4px 8px',
}}
>
{filtered.length === 0 && (
<div style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 13 }}>
Aucun résultat
</div>
)}
{filtered.map(c => (
<button
key={c.code}
type="button"
onClick={() => select(c.code)}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
width: '100%',
padding: '6px 10px',
border: 'none',
borderRadius: 5,
background: c.code === value ? 'var(--primary-light, rgba(99,102,241,0.1))' : 'transparent',
color: c.code === value ? 'var(--primary)' : 'var(--text)',
cursor: 'pointer',
fontSize: 13,
textAlign: 'left',
fontWeight: c.code === value ? 600 : 400,
}}
>
<FlagIcon code={c.code} />
<span>{c.name}</span>
<span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--text-muted)', opacity: 0.7 }}>{c.code}</span>
</button>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,321 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { fmtEUR } from '../utils/format.js';
const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
const ICONS_BASE = '/api/icons-files/';
const COLOR_DEPOT = '#22c55e';
const COLOR_RETRAIT = '#ef4444';
function hexToRgba(hex, a) {
if (!hex || hex.length < 7) return `rgba(0,0,0,${a})`;
const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16);
return `rgba(${r},${g},${b},${a})`;
}
function ChevronDown({ size = 10 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9l6 6 6-6"/>
</svg>
);
}
export default function DepotsMensuelTable({ allRows, plats, expandButton }) {
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
const [annee, setAnnee] = useState(currentYear);
const [inclureDepots, setInclureDepots] = useState(true);
const [inclureRetraits, setInclureRetraits] = useState(false);
const [libIcons, setLibIcons] = useState({});
/* ── Toggle consolidation détenteurs ── */
const [groupByNom, setGroupByNom] = useState(() => {
try { return localStorage.getItem('cl_tip_group_by_nom') === 'true'; } catch { return false; }
});
const toggleGroupByNom = () => {
setGroupByNom(v => {
const next = !v;
try { localStorage.setItem('cl_tip_group_by_nom', String(next)); } catch {}
return next;
});
};
/* Icones bibliotheque */
useEffect(() => {
api.get('/icons').then(rows => {
const m = {};
rows.forEach(r => { m[r.name] = r.filename; });
setLibIcons(m);
}).catch(() => {});
}, []);
const AppIcon = ({ name, size = 28, active = false }) => {
const filename = libIcons[name];
if (filename) return (
<img src={ICONS_BASE + filename} className="app-lib-icon" width={size} height={size}
aria-hidden="true"
style={{ opacity: active ? 1 : 0.35, display: 'block', transition: 'opacity .15s' }} />
);
return <span style={{ width: size, height: size, display: 'block', borderRadius: 4,
background: 'var(--text-muted)', opacity: active ? 0.55 : 0.2, transition: 'opacity .15s' }} />;
};
/* Annees disponibles */
const availableYears = useMemo(() => {
const set = new Set(allRows.map(r => r.date_operation?.slice(0, 4)).filter(Boolean));
return [...set].map(Number).sort((a, b) => a - b);
}, [allRows]);
/* Fenetre selecteur */
const [windowStart, setWindowStart] = useState(() => {
const idx = availableYears.indexOf(currentYear);
const safe = idx >= 0 ? idx : availableYears.length - 1;
return Math.max(0, Math.min(Math.max(0, availableYears.length - 3), safe - 1));
});
const visibleYears = availableYears.length ? availableYears.slice(windowStart, windowStart + 3) : [annee];
const canPrev = windowStart > 0;
const canNext = windowStart + 3 < availableYears.length;
/* Grille par plateforme x mois */
const { grid, stats, multiDetenteur } = useMemo(() => {
const anneeStr = String(annee);
const rows = allRows.filter(r => r.date_operation?.slice(0, 4) === anneeStr);
const byPlat = {};
for (const r of rows) {
const pid = r.plateforme_id;
if (!byPlat[pid]) {
byPlat[pid] = {
id: pid,
nom: r.plateforme_nom || '—',
investisseur_id: r.investisseur_id ?? null,
detenteur_nom: r.plateforme_detenteur_nom || null,
depots: Array(12).fill(0),
retraits: Array(12).fill(0),
};
}
const mi = parseInt(r.date_operation.slice(5, 7), 10) - 1;
if (r.type === 'depot') byPlat[pid].depots[mi] += r.montant || 0;
if (r.type === 'retrait') byPlat[pid].retraits[mi] += r.montant || 0;
}
const allPlats = Object.values(byPlat);
const multi = new Set(allPlats.map(p => p.investisseur_id).filter(v => v != null)).size > 1;
// Consolidation par nom si demandée
let consolidated;
if (groupByNom && multi) {
const byNom = {};
for (const p of allPlats) {
if (!byNom[p.nom]) {
byNom[p.nom] = { id: p.nom, nom: p.nom, investisseur_id: null, detenteur_nom: null,
depots: [...p.depots], retraits: [...p.retraits] };
} else {
for (let i = 0; i < 12; i++) {
byNom[p.nom].depots[i] += p.depots[i];
byNom[p.nom].retraits[i] += p.retraits[i];
}
}
}
consolidated = Object.values(byNom);
} else {
consolidated = allPlats;
}
const cellValue = (p, mi) => {
const d = inclureDepots ? p.depots[mi] : 0;
const rv = inclureRetraits ? p.retraits[mi] : 0;
if (inclureDepots && inclureRetraits) return d - rv;
return d + rv;
};
const grid = consolidated
.map(p => ({ ...p, months: Array.from({ length: 12 }, (_, i) => cellValue(p, i)) }))
.filter(p => p.months.some(v => v !== 0))
.sort((a, b) => b.depots.reduce((s, v) => s + v, 0) - a.depots.reduce((s, v) => s + v, 0));
const monthTotals = Array.from({ length: 12 }, (_, i) =>
grid.reduce((s, row) => s + row.months[i], 0));
const grandTotal = monthTotals.reduce((s, v) => s + v, 0);
const platTotals = grid.map(row => row.months.reduce((s, v) => s + v, 0));
const nonZero = monthTotals.filter(v => v !== 0);
const globalMoyenne = nonZero.length ? nonZero.reduce((s, v) => s + v, 0) / nonZero.length : 0;
return { grid, stats: { monthTotals, grandTotal, platTotals, globalMoyenne }, multiDetenteur: multi };
}, [allRows, annee, inclureDepots, inclureRetraits, groupByNom]);
return (
<div className="solde-chart-wrap" style={{ padding: '24px 24px 16px', marginBottom: 24 }}>
<div className="solde-chart-header">
<div className="solde-chart-info">
<div style={{ display:'flex', alignItems:'center', gap:5, flexWrap:'wrap', marginBottom:2 }}>
{inclureDepots && (
<span style={{ display:'inline-flex', alignItems:'center', gap:4,
background: hexToRgba(COLOR_DEPOT, 0.12), borderRadius:5, padding:'3px 8px' }}>
<span style={{ width:7, height:7, borderRadius:2, background: COLOR_DEPOT, flexShrink:0 }}/>
<span style={{ fontSize:13, color: COLOR_DEPOT, fontWeight:600 }}>Depots</span>
</span>
)}
{inclureRetraits && (
<span style={{ display:'inline-flex', alignItems:'center', gap:4,
background: hexToRgba(COLOR_RETRAIT, 0.12), borderRadius:5, padding:'3px 8px' }}>
<span style={{ width:7, height:7, borderRadius:2, background: COLOR_RETRAIT, flexShrink:0 }}/>
<span style={{ fontSize:13, color: COLOR_RETRAIT, fontWeight:600 }}>Retraits</span>
</span>
)}
{!inclureDepots && !inclureRetraits && (
<span style={{ fontSize:13, color:'var(--text-muted)' }}>---</span>
)}
<span style={{ fontSize:13, color:'var(--text-muted)' }}>. {annee}</span>
</div>
<div className="solde-chart-value">{fmtEUR(stats.grandTotal)}</div>
</div>
<div className="solde-chart-controls">
<button
title={inclureDepots ? 'Depots inclus' : 'Inclure les depots'}
onClick={() => setInclureDepots(v => !v)}
style={{ background: inclureDepots ? hexToRgba(COLOR_DEPOT, 0.13) : 'none',
border: '1px solid ' + (inclureDepots ? COLOR_DEPOT : 'transparent'),
borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center',
transition:'background .15s,border-color .15s', marginRight:2 }}>
<AppIcon name="depot" active={inclureDepots} />
</button>
<button
title={inclureRetraits ? 'Retraits inclus' : 'Inclure les retraits'}
onClick={() => setInclureRetraits(v => !v)}
style={{ background: inclureRetraits ? hexToRgba(COLOR_RETRAIT, 0.13) : 'none',
border: '1px solid ' + (inclureRetraits ? COLOR_RETRAIT : 'transparent'),
borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center',
transition:'background .15s,border-color .15s', marginRight:2 }}>
<AppIcon name="retrait" active={inclureRetraits} />
</button>
<div className="solde-chart-ranges">
<button className="solde-range-btn"
onClick={() => setWindowStart(w => Math.max(0, w - 1))}
disabled={!canPrev} style={{ opacity: canPrev ? 1 : 0.3 }}>&#x2039;</button>
{visibleYears.map(y => (
<button key={y}
className={'solde-range-btn' + (annee === y ? ' active' : '')}
onClick={() => setAnnee(y)}>
{y}
</button>
))}
<button className="solde-range-btn"
onClick={() => setWindowStart(w => Math.min(Math.max(0, availableYears.length - 3), w + 1))}
disabled={!canNext} style={{ opacity: canNext ? 1 : 0.3 }}>&#x203a;</button>
<button
className={'solde-range-btn' + (annee === currentYear ? ' active' : '')}
onClick={() => setAnnee(currentYear)}>
TOUT
</button>
{expandButton}
</div>
</div>
</div>
{grid.length === 0 ? (
<div style={{ marginTop: 20, color: 'var(--text-muted)', fontSize: 'var(--fs-sm)', padding: '24px 0', textAlign: 'center' }}>
{!inclureDepots && !inclureRetraits
? 'Selectionne au moins un type de mouvement.'
: 'Aucun mouvement pour ' + annee + '.'}
</div>
) : (
<div style={{ overflowX: 'auto', marginTop: 20, position: 'relative', zIndex: 0 }}>
<table className="tip-table">
<thead>
<tr>
<th className="tip-th-empty" />
<th className="tip-th-year" colSpan={12}>{annee}</th>
<th className="tip-th-empty" />
<th className="tip-th-empty" />
</tr>
<tr>
<th className="tip-th-name tip-th-name-amber">
<span style={{ display:'flex', alignItems:'center', gap:6 }}>
Plateforme
{multiDetenteur && (
<button
onClick={() => toggleGroupByNom()}
title={groupByNom ? 'Vue consolidée — cliquer pour détailler par détenteur' : 'Vue détaillée — cliquer pour consolider par plateforme'}
style={{
display:'inline-flex', alignItems:'center', gap:3,
background:'rgba(255,255,255,0.15)', border:'1px solid rgba(255,255,255,0.3)',
borderRadius:4, padding:'2px 5px', cursor:'pointer',
fontSize:10, fontWeight:600, color:'#fff', letterSpacing:'.03em',
lineHeight:1.4, whiteSpace:'nowrap', transition:'background .15s',
}}>
{groupByNom ? 'Consolidé' : 'Détaillé'}
<span style={{ display:'inline-flex', transition:'transform .2s', transform: groupByNom ? 'rotate(180deg)' : 'rotate(0deg)' }}>
<ChevronDown size={9} />
</span>
</button>
)}
</span>
</th>
{MOIS_LONG.map((m, i) => (
<th key={m}
className={'tip-th-month' + (annee === currentYear && i === currentMonth - 1 ? ' tip-th-month-current' : '')}>
{m}
</th>
))}
<th className="tip-th-total">Total</th>
<th className="tip-th-avg">Moy. mensuelle</th>
</tr>
</thead>
<tbody>
{grid.map((plat, pi) => (
<tr key={plat.id} className="tip-row-plat">
<td className="tip-td-name">
{plat.nom}
{!groupByNom && multiDetenteur && plat.detenteur_nom && (
<span style={{ marginLeft: 6, fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>
{plat.detenteur_nom}
</span>
)}
</td>
{plat.months.map((v, mi) => (
<td key={mi}
className={'tip-td-num' + (annee === currentYear && mi === currentMonth - 1 ? ' tip-col-current' : '')}
style={v < 0 ? { color: COLOR_RETRAIT } : undefined}>
{v !== 0 ? fmtEUR(v) : <span className="tip-dash">---</span>}
</td>
))}
<td className="tip-td-total" style={stats.platTotals[pi] < 0 ? { color: COLOR_RETRAIT } : undefined}>
{stats.platTotals[pi] !== 0 ? fmtEUR(stats.platTotals[pi]) : <span className="tip-dash">---</span>}
</td>
<td className="tip-td-avg">
{stats.platTotals[pi] !== 0 ? fmtEUR(stats.platTotals[pi] / 12) : <span className="tip-dash">---</span>}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="tip-footer-total">
<td className="tip-td-name">Toutes les plateformes</td>
{stats.monthTotals.map((v, i) => (
<td key={i}
className={'tip-td-num' + (annee === currentYear && i === currentMonth - 1 ? ' tip-col-current' : '')}
style={v < 0 ? { color: COLOR_RETRAIT } : undefined}>
{v !== 0 ? fmtEUR(v) : <span className="tip-dash">---</span>}
</td>
))}
<td className="tip-td-total" style={stats.grandTotal < 0 ? { color: COLOR_RETRAIT } : undefined}>
{fmtEUR(stats.grandTotal)}
</td>
<td className="tip-td-avg">
{stats.globalMoyenne !== 0 ? fmtEUR(stats.globalMoyenne) : <span className="tip-dash">---</span>}
</td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,292 @@
import { useMemo, useState, useRef, useCallback } from 'react';
/* ── Palette ────────────────────────────────────────────────── */
const PALETTE = [
'#e8547a', '#9333ea', '#0d9488', '#f59e0b',
'#3b82f6', '#10b981', '#f97316', '#06b6d4',
'#a855f7', '#84cc16', '#ec4899', '#14b8a6',
];
/* ── Algorithme treemap (binary split équilibré) ────────────── */
function buildTreemap(items, x, y, w, h) {
if (!items.length) return [];
if (items.length === 1) return [{ ...items[0], x, y, w, h }];
const total = items.reduce((s, i) => s + i.value, 0);
let best = 1, bestDiff = Infinity, acc = 0;
for (let i = 0; i < items.length - 1; i++) {
acc += items[i].value;
const diff = Math.abs(acc - (total - acc));
if (diff < bestDiff) { bestDiff = diff; best = i + 1; }
}
const g1 = items.slice(0, best);
const g2 = items.slice(best);
const r1 = g1.reduce((s, i) => s + i.value, 0) / total;
if (w >= h) {
const w1 = w * r1;
return [
...buildTreemap(g1, x, y, w1, h),
...buildTreemap(g2, x + w1, y, w - w1, h),
];
} else {
const h1 = h * r1;
return [
...buildTreemap(g1, x, y, w, h1),
...buildTreemap(g2, x, y + h1, w, h - h1),
];
}
}
/* ── Formatage : nombre entier arrondi, jamais de k€ ── */
function fmtAmount(v) {
return Math.round(v).toLocaleString('fr-FR') + ' €';
}
/* ── Word-wrap SVG : découpe un nom en lignes selon la largeur dispo ── */
function wrapText(text, maxWidth, fontSize) {
const charW = fontSize * 0.58; // largeur approx d'un caractère
const maxChars = Math.max(1, Math.floor(maxWidth / charW));
const words = text.split(' ');
const lines = [];
let current = '';
for (const word of words) {
const candidate = current ? current + ' ' + word : word;
if (candidate.length <= maxChars) {
current = candidate;
} else {
if (current) lines.push(current);
current = word.length > maxChars ? word.slice(0, maxChars - 1) + '…' : word;
}
}
if (current) lines.push(current);
return lines.slice(0, 3); // max 3 lignes
}
/* ── Composant ──────────────────────────────────────────────── */
export default function DistributionChart({ rows }) {
const [hoveredIdx, setHoveredIdx] = useState(null);
const [tooltip, setTooltip] = useState(null); // { x, y, cell }
const svgRef = useRef(null);
const wrapRef = useRef(null);
/* Solde net par plateforme */
const { data, allRetraits } = useMemo(() => {
if (!rows?.length) return { data: [], allRetraits: false };
const allRetraits = rows.every(r => r.type === 'retrait');
const byPlat = {};
for (const r of rows) {
const key = r.plateforme_nom || `#${r.plateforme_id}`;
byPlat[key] = (byPlat[key] || 0) + r.montant;
}
const data = Object.entries(byPlat)
.filter(([, v]) => v > 0)
.sort(([, a], [, b]) => b - a)
.map(([name, value], i) => ({ name, value, color: PALETTE[i % PALETTE.length] }));
return { data, allRetraits };
}, [rows]);
const total = data.reduce((s, i) => s + i.value, 0);
const W = 440, H = 290, GAP = 3;
const cells = useMemo(() => buildTreemap(data, 0, 0, W, H), [data]);
/* Conversion coordonnées SVG → pixels dans le wrapper */
const handleMouseMove = useCallback((e, cell, idx) => {
if (!svgRef.current || !wrapRef.current) return;
const svgRect = svgRef.current.getBoundingClientRect();
const wrapRect = wrapRef.current.getBoundingClientRect();
// Position relative au wrapper (pour le tooltip absolu)
const tx = e.clientX - wrapRect.left;
const ty = e.clientY - wrapRect.top;
setHoveredIdx(idx);
setTooltip({ x: tx, y: ty, cell });
}, []);
const handleMouseLeave = useCallback(() => {
setHoveredIdx(null);
setTooltip(null);
}, []);
if (!cells.length) return null;
/* Position tooltip : évite les débordements */
const TIP_W = 150, TIP_H = 66;
const tipStyle = tooltip ? (() => {
const ww = wrapRef.current?.offsetWidth || 400;
const wh = wrapRef.current?.offsetHeight || 360;
let tx = tooltip.x + 14;
let ty = tooltip.y - TIP_H / 2;
if (tx + TIP_W > ww - 4) tx = tooltip.x - TIP_W - 14;
if (ty < 4) ty = 4;
if (ty + TIP_H > wh - 4) ty = wh - TIP_H - 4;
return { left: tx, top: ty };
})() : null;
return (
<div className="dist-chart-wrap" ref={wrapRef}>
{/* ── En-tête ── */}
<div className="dist-chart-header">
<span className="dist-chart-title">Distribution</span>
<div className="dist-dropdown">
par Plateforme
<svg width="10" height="10" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9l6 6 6-6"/>
</svg>
</div>
</div>
{/* ── Treemap SVG ── */}
<div style={{ position: 'relative' }}>
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
style={{ width: '100%', height: 'auto', display: 'block' }}
onMouseLeave={handleMouseLeave}
>
<defs>
<linearGradient id="dt-shadow" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#000" stopOpacity="0" />
<stop offset="100%" stopColor="#000" stopOpacity="0.4" />
</linearGradient>
<filter id="dt-txt-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="1" stdDeviation="1.5" floodColor="#000" floodOpacity="0.55"/>
</filter>
{/* clipPath par cellule — défini dynamiquement dans chaque <g> */}
{cells.map((cell, i) => {
const PAD = 4;
const gx = cell.x + GAP / 2 + PAD;
const gy = cell.y + GAP / 2 + PAD;
const gw = Math.max(cell.w - GAP - PAD * 2, 0);
const gh = Math.max(cell.h - GAP - PAD * 2, 0);
return (
<clipPath key={i} id={`clip-${i}`}>
<rect x={gx} y={gy} width={gw} height={gh} />
</clipPath>
);
})}
</defs>
{cells.map((cell, i) => {
const gx = cell.x + GAP / 2;
const gy = cell.y + GAP / 2;
const gw = Math.max(cell.w - GAP, 0);
const gh = Math.max(cell.h - GAP, 0);
const cx = gx + gw / 2;
const cy = gy + gh / 2;
const pct = ((cell.value / total) * 100).toFixed(0);
const amt = (allRetraits ? ' ' : '') + fmtAmount(cell.value);
const isHovered = hoveredIdx === i;
/* Taille de police adaptée à la largeur disponible */
const fsAmt = Math.min(15, Math.max(10, gw / 7.5));
const fsName = Math.min(12, Math.max(8, gw / 9.5));
const fsPct = Math.min(11, Math.max(7, gw / 11));
const lineH = 15;
/* Seuils d'affichage */
const canShowAmt = gw > 36 && gh > 20;
/* Mode "paysage" : cellule plus large que haute */
const isLandscape = gw > gh;
/* Texte combiné "Nom — X %" : utilisé en paysage seulement s'il tient sur 1 ligne */
const combinedText = `${cell.name}${pct} %`;
const combinedCharW = fsName * 0.58;
const combinedFits = isLandscape && (combinedText.length * combinedCharW) <= (gw - 10);
/* Word-wrap du nom seul (mode portrait ou paysage sans place pour le combiné) */
const nameLines = (gw > 40 && gh > 30)
? wrapText(cell.name, gw - 10, fsName)
: [];
const canShowName = nameLines.length > 0;
/* Pourcentage séparé : s'assurer que la ligne % rentre dans le clipPath (PAD=4 de chaque côté) */
const CLIP_PAD = 4;
const textHeightSoFar = (canShowAmt ? lineH : 0) + (canShowName ? nameLines.length * lineH : 0);
const canShowPct = !combinedFits && gw > 44 && (gh - CLIP_PAD * 2) >= textHeightSoFar + lineH;
/* Pré-calcul des positions Y */
const textItems = [];
{
const slots = [];
if (canShowAmt) slots.push({ type: 'amt', h: lineH + 2 });
if (combinedFits) {
slots.push({ type: 'combined', text: combinedText, h: lineH });
} else {
if (canShowName) nameLines.forEach((l, li) => slots.push({ type: 'name', li, text: l, h: lineH }));
if (canShowPct) slots.push({ type: 'pct', h: lineH });
}
const totalH = slots.reduce((s, sl) => s + sl.h, 0);
let y = cy - totalH / 2 + lineH / 2;
for (const sl of slots) {
if (sl.type === 'amt') textItems.push({ key: 'amt', text: amt, fs: fsAmt, fw: '700', op: 1, y });
if (sl.type === 'combined') textItems.push({ key: 'combined', text: sl.text, fs: fsName, fw: '500', op: 0.92, y });
if (sl.type === 'name') textItems.push({ key: `n${sl.li}`, text: sl.text, fs: fsName, fw: '500', op: 0.92, y });
if (sl.type === 'pct') textItems.push({ key: 'pct', text: pct + ' %', fs: fsPct, fw: '400', op: 0.75, y });
y += sl.h;
}
}
return (
<g key={i} style={{ cursor: 'pointer' }}
onMouseMove={e => handleMouseMove(e, { ...cell, pct, amt }, i)}
>
{/* Cellule colorée */}
<rect x={gx} y={gy} width={gw} height={gh}
rx="5" fill={cell.color}
opacity={hoveredIdx !== null && !isHovered ? 0.45 : 0.9}
style={{ transition: 'opacity .15s' }}
/>
{/* Dégradé sombre */}
<rect x={gx} y={gy} width={gw} height={gh}
rx="5" fill="url(#dt-shadow)" opacity="0.35" />
{/* Bordure lumineuse au hover */}
{isHovered && (
<rect x={gx} y={gy} width={gw} height={gh}
rx="5" fill="none"
stroke="rgba(255,255,255,0.55)" strokeWidth="1.5" />
)}
{/* Labels */}
<g clipPath={`url(#clip-${i})`}>
{textItems.map(l => (
<text key={l.key} x={cx} y={l.y}
textAnchor="middle" dominantBaseline="middle"
fill="white"
fillOpacity={hoveredIdx !== null && !isHovered ? 0.3 : l.op}
fontSize={l.fs} fontWeight={l.fw}
fontFamily="system-ui,-apple-system,sans-serif"
filter="url(#dt-txt-shadow)"
style={{ transition: 'fill-opacity .15s' }}>
{l.text}
</text>
))}
</g>
</g>
);
})}
</svg>
{/* ── Tooltip ── */}
{tooltip && tipStyle && (
<div className="dist-tooltip" style={{ left: tipStyle.left, top: tipStyle.top }}>
<div className="dist-tooltip-dot" style={{ background: tooltip.cell.color }} />
<div>
<div className="dist-tooltip-amt">{tooltip.cell.amt}</div>
<div className="dist-tooltip-name">{tooltip.cell.name}</div>
<div className="dist-tooltip-pct">{tooltip.cell.pct} %</div>
</div>
</div>
)}
</div>
</div>
);
}
+591
View File
@@ -0,0 +1,591 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { useInteretsChart } from '../context/InteretsChartContext.jsx';
import { fmtEUR, fmtDate } from '../utils/format.js';
import Modal from './Modal.jsx';
const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
function hexToRgba(hex, a) {
if (!hex || hex.length < 7) return `rgba(79,168,232,${a})`;
const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16);
return `rgba(${r},${g},${b},${a})`;
}
const round2 = v => Math.round(v * 100) / 100;
/**
* DrillCellPanel
*
* Props :
* cell { platId, platNom, annee, mois, moisLabel } | null
* platId peut être null → toutes les plateformes
* onClose () => void (masqué si alwaysOpen=true)
* alwaysOpen bool — cache le bouton Fermer, ajuste le style
* pfuRates []
* activeView 'single'|'all'
* activeId number|null
* plateformes [] — pour le sélecteur (optionnel)
* investissements [] — pour le calcul bulk (optionnel)
* onBulkDone () => void — callback après validation en masse
*/
export default function DrillCellPanel({
cell, onClose, alwaysOpen = false,
pfuRates, activeView, activeId,
onEditRecu, onEditProjet, refreshKey,
investissements, plateformes, onBulkDone,
}) {
const {
inclureInterets, setInclureInterets,
inclureCapital, setInclureCapital,
inclureCashback, setInclureCashback,
netMode,
showActual, showProjected,
chartInterets, chartCapital, chartCashback,
} = useInteretsChart();
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
/* filterPlatId : '' = toutes, sinon l'id (string) de la plateforme sélectionnée dans le dropdown */
const [filterPlatId, setFilterPlatId] = useState('');
const [showRecus, setShowRecus] = useState(showActual);
const [showProjetes, setShowProjetes] = useState(showProjected);
/* ── Bulk validation ── */
const [bulkModal, setBulkModal] = useState(false);
const [bulkItems, setBulkItems] = useState([]);
const [bulkProcessing, setBulkProcessing] = useState(false);
const [bulkProgress, setBulkProgress] = useState(0);
const [bulkDone, setBulkDone] = useState(false);
/* ── Reset filtres quand la cellule change ── */
useEffect(() => {
if (!cell) { setData(null); return; }
setShowRecus(showActual);
setShowProjetes(showProjected);
/* Quand une cellule spécifique est cliquée, pré-sélectionner sa plateforme */
setFilterPlatId(cell.platId ? String(cell.platId) : '');
}, [cell?.platId, cell?.annee, cell?.mois]);
/* ── Fetch quand cellule OU filterPlatId change ── */
useEffect(() => {
if (!cell) { setData(null); return; }
setLoading(true);
setData(null);
const params = {
annee: cell.annee,
mois: cell.mois,
...(filterPlatId ? { plateforme_id: filterPlatId } : {}),
...(activeView === 'all' ? { scope: 'all' } : {}),
};
api.get('/dashboard/detail-cellule', params)
.then(d => setData(d))
.catch(() => setData({ recus: [], projetes: [] }))
.finally(() => setLoading(false));
}, [cell?.annee, cell?.mois, filterPlatId, activeView, activeId, refreshKey]);
/* ── Taux PFU pour l'année de la cellule ── */
const pfuReduction = useMemo(() => {
if (!pfuRates?.length || !cell) return 0;
const r = pfuRates.find(r => r.annee === cell.annee)
?? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]);
return (r.prelev_sociaux + r.impot_revenu) / 100;
}, [pfuRates, cell]);
/* ── Données filtrées (la plateforme est gérée côté fetch) ── */
const recus = useMemo(() => {
if (!data) return [];
return data.recus;
}, [data]);
const projetes = useMemo(() => {
if (!data) return [];
return data.projetes;
}, [data]);
if (!cell) return null;
/* ── Afficher la colonne Plateforme quand on est en mode "toutes" ── */
const showPlatCol = !filterPlatId;
const multiDetenteur = plateformes && new Set(plateformes.map(p => p.investisseur_id)).size > 1;
/* ── Calcul valeur ligne reçue ── */
const recuRowValue = (r) => {
let v = 0;
if (inclureInterets) v += netMode ? r.interets_nets : r.interets_bruts;
if (inclureCashback) v += r.cashback ?? 0;
if (inclureCapital) v += r.capital ?? 0;
return v;
};
/* ── Calcul valeur ligne projetée ── */
const projRowValue = (p) => {
let v = 0;
if (inclureInterets) v += netMode ? p.interets_prevus * (1 - pfuReduction) : p.interets_prevus;
if (inclureCapital) v += p.capital_prevu ?? 0;
return v;
};
/* ── Totaux ── */
const totalRecus = recus.reduce((s, r) => s + recuRowValue(r), 0);
const totalProjetes = projetes.reduce((s, p) => s + projRowValue(p), 0);
const grandTotal = (showRecus ? totalRecus : 0) + (showProjetes ? totalProjetes : 0);
/* ── Colonnes dynamiques ── */
const cols = [
inclureInterets && { key: 'interets', label: netMode ? 'Intérêts nets' : 'Intérêts bruts', color: chartInterets },
inclureCapital && { key: 'capital', label: 'Capital', color: chartCapital },
inclureCashback && { key: 'cashback', label: 'Cashback', color: chartCashback },
].filter(Boolean);
/* Date + Plateforme(opt) + Projet + Détenteur + cols + Total */
const colCount = 3 + (showPlatCol ? 1 : 0) + cols.length + 1;
/* Titre du header */
const headerTitle = filterPlatId
? `${plateformes?.find(p => String(p.id) === filterPlatId)?.nom ?? 'Plateforme'}${cell.moisLabel} ${cell.annee}`
: `Toutes les plateformes — ${cell.moisLabel} ${cell.annee}`;
/* ── Bulk : construction des payloads ── */
const openBulkModal = () => {
if (!projetes.length) return;
const items = projetes.map(p => {
const inv = investissements?.find(i => i.id === p.investissement_id);
const plat = inv ? plateformes?.find(pl => pl.id === inv.plateforme_id) : null;
const bruts = p.interets_prevus || 0;
const year = p.date_prevue ? Number(p.date_prevue.slice(0, 4)) : cell.annee;
const rates = pfuRates?.find(r => r.annee === year)
?? (pfuRates?.length ? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]) : null);
const methode = plat && plat.methode_remboursement !== 'choix_investisseur'
? plat.methode_remboursement
: (inv?.methode_remboursement || 'portefeuille');
const hasLocalTax = plat?.fiscalite === 'avec_fiscalite_locale' && plat?.taux_fiscalite_locale;
const taxe_locale = hasLocalTax ? round2(bruts * plat.taux_fiscalite_locale / 100) : 0;
const brutsApresLocal = hasLocalTax ? round2(bruts - taxe_locale) : bruts;
return {
_label: p.nom_projet,
_plat: p.plateforme_nom,
_date: p.date_prevue,
_capital: p.capital_prevu || 0,
_interets: bruts,
_total: (p.capital_prevu || 0) + bruts,
investissement_id: p.investissement_id,
date_remb: p.date_prevue,
capital: p.capital_prevu || 0,
cashback: 0,
interets_bruts_avant_local: hasLocalTax ? bruts : 0,
taxe_locale,
interets_bruts: brutsApresLocal,
prelev_sociaux: rates ? round2(brutsApresLocal * rates.prelev_sociaux / 100) : 0,
prelev_forfaitaire: rates ? round2(brutsApresLocal * rates.impot_revenu / 100) : 0,
statut: 'paye',
notes: '',
methode_remboursement: methode,
compte_id: methode === 'compte_courant' ? (inv?.compte_id || '') : '',
};
});
setBulkItems(items);
setBulkProgress(0);
setBulkDone(false);
setBulkProcessing(false);
setBulkModal(true);
};
const runBulk = async () => {
setBulkProcessing(true);
let done = 0;
for (const item of bulkItems) {
const { _label, _plat, _date, _capital, _interets, _total, ...payload } = item;
try { await api.post('/remboursements', payload); } catch (e) { /* continuer */ }
done++;
setBulkProgress(done);
}
setBulkProcessing(false);
setBulkDone(true);
if (onBulkDone) onBulkDone();
};
const closeBulkModal = () => {
setBulkModal(false);
setBulkItems([]);
setBulkProgress(0);
setBulkDone(false);
setBulkProcessing(false);
};
/* ── Rendu ── */
return (
<div style={{
margin: alwaysOpen ? '0 24px 28px' : '0 24px 28px',
padding: 0,
border: '2px solid var(--primary)',
borderRadius: 10,
overflow: 'hidden',
overflowX: 'auto',
background: 'var(--surface)',
}}>
{/* ── Header ── */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 16px',
background: 'var(--primary)',
color: '#fff',
}}>
<strong style={{ fontSize: '1rem' }}>{headerTitle}</strong>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{[
{ active: inclureInterets, toggle: () => setInclureInterets(v => !v), label: netMode ? 'Intérêts nets' : 'Intérêts bruts' },
{ active: inclureCapital, toggle: () => setInclureCapital(v => !v), label: 'Capital' },
{ active: inclureCashback, toggle: () => setInclureCashback(v => !v), label: 'Cashback' },
].map(btn => (
<button key={btn.label} onClick={() => btn.toggle()} style={{
border: `1px solid ${btn.active ? 'rgba(255,255,255,0.8)' : 'rgba(255,255,255,0.3)'}`,
background: btn.active ? 'rgba(255,255,255,0.2)' : 'transparent',
borderRadius: 6, padding: '3px 10px',
fontSize: '0.8rem', fontWeight: btn.active ? 700 : 400,
color: '#fff', cursor: 'pointer', transition: 'all .15s',
}}>{btn.label}</button>
))}
{!alwaysOpen && (
<button onClick={onClose} title="Fermer" style={{
background: 'rgba(255,255,255,0.2)', border: '1px solid rgba(255,255,255,0.4)',
borderRadius: 6, padding: '3px 10px', color: '#fff', cursor: 'pointer',
fontSize: '0.85rem', fontWeight: 700,
}}></button>
)}
</div>
</div>
{/* ── Barre filtres ── */}
<div style={{
display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
padding: '10px 16px', borderBottom: '1px solid var(--border)',
background: 'var(--surface-2)',
}}>
{/* Sélecteur plateforme */}
{plateformes && plateformes.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<label style={{ fontSize: '0.8rem', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Plateforme</label>
<select
value={filterPlatId}
onChange={e => setFilterPlatId(e.target.value)}
style={{ fontSize: 'var(--fs-sm)', padding: '3px 8px', borderRadius: 6,
border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)' }}>
<option value="">Toutes</option>
{plateformes.map(p => <option key={p.id} value={String(p.id)}>{p.nom}{multiDetenteur && p.investisseur_nom ? `${p.investisseur_nom}` : ''}</option>)}
</select>
</div>
)}
{/* Toggle Reçus / Projetés */}
<div style={{ display: 'inline-flex', background: 'var(--surface)', borderRadius: 8, padding: 3, gap: 2, marginLeft: 'auto' }}>
{[
{ key: 'recus', label: 'Reçus', active: showRecus, toggle: () => setShowRecus(v => !v) },
{ key: 'projetes', label: 'Projetés', active: showProjetes, toggle: () => setShowProjetes(v => !v) },
].map(btn => (
<button key={btn.key} onClick={() => btn.toggle()} style={{
border: 'none', cursor: 'pointer', padding: '4px 14px', borderRadius: 6,
fontSize: 'var(--fs-sm)',
fontWeight: btn.active ? 600 : 400,
background: btn.active ? 'var(--primary)' : 'transparent',
color: btn.active ? '#fff' : 'var(--text-muted)',
boxShadow: btn.active ? '0 1px 3px rgba(0,0,0,0.13)' : 'none',
transition: 'all .15s',
}}>{btn.label}</button>
))}
</div>
</div>
{/* ── Corps ── */}
{loading && (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>Chargement</div>
)}
{!loading && data && (
<div>
<table className="drill-table" style={{ width: '100%', minWidth: '100%', fontSize: 'var(--fs-sm)' }}>
<thead>
<tr style={{ background: 'var(--surface-2)', borderBottom: '1px solid var(--border)' }}>
<th style={thStyle}>Date</th>
{showPlatCol && <th style={thStyle}>Plateforme</th>}
<th style={{ ...thStyle, width: "100%" }}>Projet</th>
<th style={thStyle}>Détenteur</th>
{cols.map(c => (
<th key={c.key} style={{ ...thStyle, ...numStyle, color: c.color }}>{c.label}</th>
))}
<th style={{ ...thStyle, ...numStyle }}>Total ligne</th>
</tr>
</thead>
{/* ── Section Reçus ── */}
{showRecus && (
<tbody>
<tr>
<td colSpan={colCount} style={{
padding: '6px 12px', fontWeight: 700, fontSize: '0.78rem',
background: hexToRgba(chartInterets, 0.07),
color: 'var(--text-muted)', letterSpacing: '.04em', textTransform: 'uppercase',
}}>
Reçus ({recus.length})
</td>
</tr>
{recus.length === 0 ? (
<tr><td colSpan={colCount} style={{ padding: '10px 16px', color: 'var(--text-muted)', fontStyle: 'italic' }}>
Aucun remboursement reçu ce mois
</td></tr>
) : recus.map(r => (
<tr key={r.id}
style={{ borderBottom: '1px solid var(--border)', cursor: onEditRecu ? 'pointer' : 'default' }}
title={onEditRecu ? 'Modifier ce remboursement' : undefined}
onClick={() => onEditRecu && onEditRecu(r)}
>
<td style={tdStyle}>{fmtDate(r.date_remb)}</td>
{showPlatCol && <td style={{ ...tdStyle, fontWeight: 500 }}>{r.plateforme_nom || '—'}</td>}
<td style={tdStyle}>{r.nom_projet}</td>
<td style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: '0.8em' }}>{r.detenteur_nom || '—'}</td>
{cols.map(c => {
let v;
if (c.key === 'interets') v = netMode ? r.interets_nets : r.interets_bruts;
else if (c.key === 'capital') v = r.capital ?? 0;
else v = r.cashback ?? 0;
return <td key={c.key} style={{ ...tdStyle, ...numStyle }}>{fmtEUR(v)}</td>;
})}
<td style={{ ...tdStyle, ...numStyle, fontWeight: 600 }}>{fmtEUR(recuRowValue(r))}</td>
</tr>
))}
{recus.length > 0 && (
<tr className="drill-row-fixed" style={{ background: 'var(--surface-2)', fontWeight: 700 }}>
<td colSpan={3 + (showPlatCol ? 1 : 0)} style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: '0.8em' }}>
Sous-total reçus
</td>
{cols.map(c => {
const sum = recus.reduce((s, r) => {
if (c.key === 'interets') return s + (netMode ? r.interets_nets : r.interets_bruts);
if (c.key === 'capital') return s + (r.capital ?? 0);
return s + (r.cashback ?? 0);
}, 0);
return <td key={c.key} style={{ ...tdStyle, ...numStyle }}>{fmtEUR(sum)}</td>;
})}
<td style={{ ...tdStyle, ...numStyle }}>{fmtEUR(totalRecus)}</td>
</tr>
)}
</tbody>
)}
{/* ── Section Projetés ── */}
{showProjetes && (
<tbody>
<tr>
<td colSpan={colCount} style={{
padding: '6px 12px', fontWeight: 700, fontSize: '0.78rem',
background: 'rgba(148,163,184,0.08)',
color: 'var(--text-muted)', letterSpacing: '.04em', textTransform: 'uppercase',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>Projetés ({projetes.length})</span>
{projetes.length > 0 && (
<button
onClick={() => openBulkModal()}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
padding: '3px 10px', borderRadius: 5, cursor: 'pointer',
fontSize: '0.75rem', fontWeight: 600, letterSpacing: 'normal',
textTransform: 'none',
border: '1px solid var(--primary)',
background: 'var(--primary)', color: '#fff',
transition: 'opacity .15s',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
Valider en masse
</button>
)}
</div>
</td>
</tr>
{projetes.length === 0 ? (
<tr><td colSpan={colCount} style={{ padding: '10px 16px', color: 'var(--text-muted)', fontStyle: 'italic' }}>
Aucune projection ce mois
</td></tr>
) : projetes.map(p => (
<tr key={p.id}
style={{ borderBottom: '1px solid var(--border)', opacity: 0.85, cursor: onEditProjet ? 'pointer' : 'default' }}
title={onEditProjet ? 'Saisir ce remboursement' : undefined}
onClick={() => onEditProjet && onEditProjet(p)}
>
<td style={{ ...tdStyle, fontStyle: 'italic', color: 'var(--text-muted)' }}>{fmtDate(p.date_prevue)}</td>
{showPlatCol && <td style={{ ...tdStyle, fontStyle: 'italic', fontWeight: 500 }}>{p.plateforme_nom || '—'}</td>}
<td style={{ ...tdStyle, fontStyle: 'italic' }}>{p.nom_projet}</td>
<td style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: '0.8em' }}>{p.detenteur_nom || '—'}</td>
{cols.map(c => {
let v;
if (c.key === 'interets') v = netMode ? p.interets_prevus * (1 - pfuReduction) : p.interets_prevus;
else if (c.key === 'capital') v = p.capital_prevu ?? 0;
else v = 0;
return <td key={c.key} style={{ ...tdStyle, ...numStyle, fontStyle: 'italic', color: 'var(--text-muted)' }}>{fmtEUR(v)}</td>;
})}
<td style={{ ...tdStyle, ...numStyle, fontStyle: 'italic', color: 'var(--text-muted)' }}>{fmtEUR(projRowValue(p))}</td>
</tr>
))}
{projetes.length > 0 && (
<tr className="drill-row-fixed" style={{ background: 'var(--surface-2)', fontWeight: 700 }}>
<td colSpan={3 + (showPlatCol ? 1 : 0)} style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: '0.8em' }}>
Sous-total projetés
</td>
{cols.map(c => {
const sum = projetes.reduce((s, p) => {
if (c.key === 'interets') return s + (netMode ? p.interets_prevus * (1 - pfuReduction) : p.interets_prevus);
if (c.key === 'capital') return s + (p.capital_prevu ?? 0);
return s;
}, 0);
return <td key={c.key} style={{ ...tdStyle, ...numStyle }}>{fmtEUR(sum)}</td>;
})}
<td style={{ ...tdStyle, ...numStyle }}>{fmtEUR(totalProjetes)}</td>
</tr>
)}
</tbody>
)}
{/* ── Grand total ── */}
<tfoot>
<tr className="drill-row-fixed" style={{
background: 'var(--primary)', color: '#fff',
fontWeight: 700, fontSize: '0.9rem', pointerEvents: 'none',
}}>
<td colSpan={3 + (showPlatCol ? 1 : 0)} style={{ ...tdStyle, color: '#fff' }}>Total</td>
{cols.map(c => {
const sumR = showRecus ? recus.reduce((s, r) => {
if (c.key === 'interets') return s + (netMode ? r.interets_nets : r.interets_bruts);
if (c.key === 'capital') return s + (r.capital ?? 0);
return s + (r.cashback ?? 0);
}, 0) : 0;
const sumP = showProjetes ? projetes.reduce((s, p) => {
if (c.key === 'interets') return s + (netMode ? p.interets_prevus * (1 - pfuReduction) : p.interets_prevus);
if (c.key === 'capital') return s + (p.capital_prevu ?? 0);
return s;
}, 0) : 0;
return <td key={c.key} style={{ ...tdStyle, ...numStyle, color: '#fff' }}>{fmtEUR(sumR + sumP)}</td>;
})}
<td style={{ ...tdStyle, ...numStyle, color: '#fff', fontSize: '1rem' }}>
{fmtEUR(grandTotal)}
</td>
</tr>
</tfoot>
</table>
</div>
)}
{/* ── Modale validation en masse ── */}
{bulkModal && (
<Modal
open={bulkModal}
title={bulkDone ? 'Validation terminée' : `Valider ${bulkItems.length} remboursement${bulkItems.length > 1 ? 's' : ''}`}
onClose={bulkProcessing ? undefined : closeBulkModal}
width={620}
footer={
bulkDone ? (
<button className="btn-primary" onClick={() => closeBulkModal()}>Fermer</button>
) : bulkProcessing ? (
<span style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>
Traitement en cours {bulkProgress}/{bulkItems.length}
</span>
) : (
<>
<button className="btn-secondary" onClick={() => closeBulkModal()}>Annuler</button>
<button className="btn-primary" onClick={() => runBulk()}>
Enregistrer {bulkItems.length} remboursement{bulkItems.length > 1 ? 's' : ''}
</button>
</>
)
}
>
{bulkProcessing && (
<div style={{ marginBottom: 16 }}>
<div style={{ height: 6, borderRadius: 3, background: 'var(--border)', overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 3, background: 'var(--primary)',
width: `${bulkItems.length > 0 ? (bulkProgress / bulkItems.length) * 100 : 0}%`,
transition: 'width .3s ease',
}} />
</div>
</div>
)}
{bulkDone && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, padding: '12px 0' }}>
<div style={{ width: 48, height: 48, borderRadius: '50%', background: 'var(--success)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
</div>
<p style={{ margin: 0, fontWeight: 600, fontSize: '1rem' }}>
{bulkItems.length} remboursement{bulkItems.length > 1 ? 's' : ''} enregistré{bulkItems.length > 1 ? 's' : ''}
</p>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.88rem' }}>
{cell.moisLabel} {cell.annee}
</p>
</div>
)}
{!bulkDone && (
<>
<p style={{ margin: '0 0 12px', color: 'var(--text-muted)', fontSize: '0.88rem' }}>
Les remboursements suivants vont être créés d'après les projections de <strong>{cell.moisLabel} {cell.annee}</strong>.
Les prélèvements fiscaux sont estimés à partir des taux PFU de l'année.
</p>
<div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.88rem' }}>
<thead>
<tr style={{ background: 'var(--surface-2)', borderBottom: '1px solid var(--border)' }}>
{showPlatCol && <th style={thStyle}>Plateforme</th>}
<th style={{ ...thStyle, width: "100%" }}>Projet</th>
<th style={{ ...thStyle, ...numStyle }}>Date</th>
<th style={{ ...thStyle, ...numStyle }}>Capital</th>
<th style={{ ...thStyle, ...numStyle }}>Intérêts bruts</th>
<th style={{ ...thStyle, ...numStyle }}>Total brut</th>
</tr>
</thead>
<tbody>
{bulkItems.map((item, i) => (
<tr key={i} style={{ borderBottom: '1px solid var(--border)' }}>
{showPlatCol && <td style={tdStyle}>{item._plat || '—'}</td>}
<td style={tdStyle}>{item._label}</td>
<td style={{ ...tdStyle, ...numStyle }}>{fmtDate(item._date)}</td>
<td style={{ ...tdStyle, ...numStyle }}>{fmtEUR(item._capital)}</td>
<td style={{ ...tdStyle, ...numStyle }}>{fmtEUR(item._interets)}</td>
<td style={{ ...tdStyle, ...numStyle, fontWeight: 600 }}>{fmtEUR(item._total)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr style={{ background: 'var(--surface-2)', fontWeight: 700 }}>
<td colSpan={showPlatCol ? 5 : 4} style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: '0.8em' }}>Total</td>
<td style={{ ...tdStyle, ...numStyle }}>{fmtEUR(bulkItems.reduce((s, i) => s + i._total, 0))}</td>
</tr>
</tfoot>
</table>
</div>
</>
)}
</Modal>
)}
</div>
);
}
/* ── Styles partagés ── */
const thStyle = {
padding: '7px 12px',
textAlign: 'left',
fontWeight: 600,
fontSize: '0.78rem',
letterSpacing: '.03em',
color: 'var(--text-muted)',
whiteSpace: 'nowrap',
borderBottom: '1px solid var(--border)',
};
const tdStyle = { padding: '7px 12px', verticalAlign: 'middle', whiteSpace: 'nowrap' };
const numStyle = { textAlign: 'right' };
+285
View File
@@ -0,0 +1,285 @@
import { useMemo, useState, useRef } from 'react';
/* ── Constantes ─────────────────────────────────────────────── */
const COLOR = '#4fa8e8';
const BG = '#070c15';
const GRID = 'rgba(255,255,255,0.055)';
const LABEL = '#4a5568';
const RANGES = ['1J', '7J', '1M', '3M', 'YTD', '1A', 'TOUT'];
const MOIS_COURT = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.'];
/* ── Helpers ────────────────────────────────────────────────── */
function fmtK(v) {
if (v === 0) return '0 €';
const abs = Math.abs(v);
if (abs >= 1000) return (v / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 1 }) + ' k €';
return v.toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' €';
}
function fmtAxisDate(dateStr, range) {
const d = new Date(dateStr + 'T00:00:00');
const day = String(d.getDate()).padStart(2, '0');
const mon = MOIS_COURT[d.getMonth()];
const yr = d.getFullYear();
if (range === '1J' || range === '7J') return `${day}/${String(d.getMonth()+1).padStart(2,'0')}`;
if (range === '1M' || range === '3M') return `${day} ${mon}`;
return `${day}/${String(d.getMonth()+1).padStart(2,'0')}/${String(yr).slice(2)}`;
}
function fmtValueDisplay(v) {
return v.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €';
}
function fmtTodayFull() {
return new Date().toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' });
}
/* ── Composant ──────────────────────────────────────────────── */
export default function InteretsChart({ rows, netMode }) {
const [range, setRange] = useState('TOUT');
const [hover, setHover] = useState(null);
const svgRef = useRef(null);
const wrapRef = useRef(null);
/* ── 1. Cumul complet ── */
const allPoints = useMemo(() => {
if (!rows?.length) return [];
const byDate = {};
for (const r of rows) {
const d = r.date_remb.slice(0, 10);
const val = netMode ? (r.interets_nets || 0) : (r.interets_bruts || 0);
byDate[d] = (byDate[d] || 0) + val;
}
let cum = 0;
return Object.keys(byDate).sort().map(date => {
cum += byDate[date];
return { date, value: cum };
});
}, [rows, netMode]);
const todayStr = new Date().toISOString().slice(0, 10);
const currentValue = allPoints.length ? allPoints[allPoints.length - 1].value : 0;
/* ── 2. Filtrage par plage ── */
const points = useMemo(() => {
if (!allPoints.length) return [];
if (range === 'TOUT') {
const pts = [...allPoints];
if (pts[pts.length - 1].date < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value });
return pts;
}
const now = new Date();
let fromDate = new Date(now);
switch (range) {
case '1J': fromDate.setDate(now.getDate() - 1); break;
case '7J': fromDate.setDate(now.getDate() - 7); break;
case '1M': fromDate.setMonth(now.getMonth() - 1); break;
case '3M': fromDate.setMonth(now.getMonth() - 3); break;
case 'YTD': fromDate = new Date(now.getFullYear(), 0, 1); break;
case '1A': fromDate.setFullYear(now.getFullYear() - 1); break;
}
const fromStr = fromDate.toISOString().slice(0, 10);
const before = allPoints.filter(p => p.date < fromStr);
const startV = before.length ? before[before.length - 1].value : 0;
const after = allPoints.filter(p => p.date >= fromStr);
const pts = [{ date: fromStr, value: startV }, ...after];
if (pts[pts.length - 1].date < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value });
return pts;
}, [allPoints, range, todayStr]);
/* ── 3. SVG dimensions ── */
const W = 900, H = 260;
const PAD = { top: 16, right: 16, bottom: 32, left: 70 };
const plotW = W - PAD.left - PAD.right;
const plotH = H - PAD.top - PAD.bottom;
/* ── 4. Échelles ── */
const { xScale, yScale, yTicks, xTicks, minDate, maxDate, yZero } = useMemo(() => {
if (points.length < 2) return {};
const vals = points.map(p => p.value);
const dataMin = Math.min(...vals);
const dataMax = Math.max(...vals);
const lo = Math.min(0, dataMin);
const hi = Math.max(0, dataMax);
const pad = (hi - lo) * 0.1 || 1;
const scaleLo = lo - (lo < 0 ? pad : 0);
const scaleHi = hi + pad;
const valRange = scaleHi - scaleLo || 1;
const ts = points.map(p => new Date(p.date + 'T00:00:00').getTime());
const minDt = ts[0];
const maxDt = ts[ts.length - 1];
const dtRange = maxDt - minDt || 1;
const xScale = d => PAD.left + ((new Date(d + 'T00:00:00').getTime() - minDt) / dtRange) * plotW;
const yScale = v => PAD.top + plotH - ((v - scaleLo) / valRange) * plotH;
const step = (scaleHi - scaleLo) / 4;
const yTicks = Array.from({ length: 5 }, (_, i) => ({ v: scaleLo + step * i, y: yScale(scaleLo + step * i) }));
const nX = Math.min(8, points.length);
const xTicks = Array.from({ length: nX }, (_, i) => {
const idx = Math.round((i / (nX - 1)) * (points.length - 1));
return points[idx];
});
return { xScale, yScale, yTicks, xTicks, minDate: minDt, maxDate: maxDt, yZero: yScale(0) };
}, [points, plotW, plotH]);
/* ── 5. Chemins SVG ── */
const { linePath, areaPath } = useMemo(() => {
if (!xScale || points.length < 2) return { linePath: '', areaPath: '' };
let line = `M ${xScale(points[0].date).toFixed(1)},${yScale(points[0].value).toFixed(1)}`;
for (let i = 1; i < points.length; i++) {
line += ` H ${xScale(points[i].date).toFixed(1)} V ${yScale(points[i].value).toFixed(1)}`;
}
const zeroY = yZero?.toFixed(1) ?? (PAD.top + plotH).toFixed(1);
const area = `${line} V ${zeroY} H ${PAD.left} Z`;
return { linePath: line, areaPath: area };
}, [points, xScale, yScale, yZero]);
/* ── 6. Hover ── */
const handleMouseMove = (e) => {
if (!svgRef.current || !xScale || points.length < 2) return;
const rect = svgRef.current.getBoundingClientRect();
const svgX = ((e.clientX - rect.left) / rect.width) * W;
const t = minDate + ((svgX - PAD.left) / plotW) * (maxDate - minDate);
let nearest = points[0], minDiff = Infinity;
for (const p of points) {
const diff = Math.abs(new Date(p.date + 'T00:00:00').getTime() - t);
if (diff < minDiff) { minDiff = diff; nearest = p; }
}
setHover({ x: xScale(nearest.date), y: yScale(nearest.value), value: nearest.value, date: nearest.date });
};
const tooltipStyle = useMemo(() => {
if (!hover) return null;
const xPct = (hover.x / W) * 100;
const yPct = (hover.y / H) * 100;
const anchorRight = xPct > 65;
return {
position: 'absolute',
top: `calc(${yPct}% - 64px)`,
left: anchorRight ? 'auto' : `calc(${xPct}% + 12px)`,
right: anchorRight ? `calc(${100 - xPct}% + 12px)` : 'auto',
transform: 'none',
pointerEvents: 'none',
};
}, [hover]);
if (!allPoints.length) return null;
const displayDate = hover
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })
: fmtTodayFull();
const displayValue = hover ? fmtValueDisplay(hover.value) : fmtValueDisplay(currentValue);
const tooltipDate = hover
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
: null;
return (
<div className="solde-chart-wrap" ref={wrapRef} style={{ position: 'relative' }}>
{/* ── En-tête ── */}
<div className="solde-chart-header">
<div className="solde-chart-info">
<div className="solde-chart-date">{displayDate}</div>
<div className="solde-chart-value">{displayValue}</div>
</div>
<div className="solde-chart-controls" style={{ gap: 8 }}>
{/* Plages temporelles */}
<div className="solde-chart-ranges">
{RANGES.map(r => (
<button key={r}
className={`solde-range-btn${range === r ? ' active' : ''}`}
onClick={() => { setRange(r); setHover(null); }}>
{r}
</button>
))}
</div>
</div>
</div>
{/* ── SVG ── */}
{xScale && (
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
style={{ width: '100%', height: 'auto', display: 'block', cursor: 'crosshair' }}
onMouseMove={handleMouseMove}
onMouseLeave={() => setHover(null)}
>
<defs>
<linearGradient id="ig-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={COLOR} stopOpacity="0.22" />
<stop offset="70%" stopColor={COLOR} stopOpacity="0.06" />
<stop offset="100%" stopColor={COLOR} stopOpacity="0" />
</linearGradient>
<filter id="ig-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
{/* Grille horizontale */}
{yTicks.map(({ v, y }) => (
<g key={v}>
<line x1={PAD.left} y1={y} x2={W - PAD.right} y2={y}
stroke={GRID} strokeWidth="1" strokeDasharray="3 5" />
<text x={PAD.left - 10} y={y + 4} textAnchor="end"
fill={LABEL} fontSize="11" fontFamily="system-ui,sans-serif">
{fmtK(v)}
</text>
</g>
))}
{/* Ligne zéro */}
{yZero && yZero > PAD.top && yZero < PAD.top + plotH && (
<line x1={PAD.left} y1={yZero} x2={W - PAD.right} y2={yZero}
stroke="rgba(255,255,255,0.18)" strokeWidth="1" />
)}
{/* Remplissage dégradé */}
<path d={areaPath} fill="url(#ig-fill)" />
{/* Ligne principale */}
<path d={linePath} fill="none" stroke={COLOR} strokeWidth="1"
filter="url(#ig-glow)" strokeLinejoin="round" />
{/* Labels axe X */}
{xTicks.map((p, i) => {
const anchor = i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle';
return (
<text key={i} x={xScale(p.date)} y={H - 8}
textAnchor={anchor} fill={LABEL} fontSize="10"
fontFamily="system-ui,sans-serif">
{fmtAxisDate(p.date, range)}
</text>
);
})}
{/* Ligne verticale + point hover */}
{hover && (
<g>
<line x1={hover.x} y1={PAD.top} x2={hover.x} y2={PAD.top + plotH}
stroke="rgba(255,255,255,0.12)" strokeWidth="1" strokeDasharray="3 4" />
<circle cx={hover.x} cy={hover.y} r="4.5"
fill={COLOR} stroke={BG} strokeWidth="2" />
</g>
)}
</svg>
)}
{/* ── Tooltip ── */}
{hover && tooltipStyle && (
<div style={tooltipStyle}>
<div className="sg-tooltip">
<span className="sg-tooltip-date">{tooltipDate}</span>
<span className="sg-tooltip-value">{fmtValueDisplay(hover.value)}</span>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,255 @@
import { useMemo, useState, useRef, useCallback } from 'react';
/* ── Palette ────────────────────────────────────────────────── */
const PALETTE = [
'#e8547a', '#9333ea', '#0d9488', '#f59e0b',
'#3b82f6', '#10b981', '#f97316', '#06b6d4',
'#a855f7', '#84cc16', '#ec4899', '#14b8a6',
];
/* ── Algorithme treemap (binary split équilibré) ─────────────── */
function buildTreemap(items, x, y, w, h) {
if (!items.length) return [];
if (items.length === 1) return [{ ...items[0], x, y, w, h }];
const total = items.reduce((s, i) => s + i.value, 0);
let best = 1, bestDiff = Infinity, acc = 0;
for (let i = 0; i < items.length - 1; i++) {
acc += items[i].value;
const diff = Math.abs(acc - (total - acc));
if (diff < bestDiff) { bestDiff = diff; best = i + 1; }
}
const g1 = items.slice(0, best);
const g2 = items.slice(best);
const r1 = g1.reduce((s, i) => s + i.value, 0) / total;
if (w >= h) {
const w1 = w * r1;
return [...buildTreemap(g1, x, y, w1, h), ...buildTreemap(g2, x + w1, y, w - w1, h)];
} else {
const h1 = h * r1;
return [...buildTreemap(g1, x, y, w, h1), ...buildTreemap(g2, x, y + h1, w, h - h1)];
}
}
function fmtAmount(v) {
return Math.round(v).toLocaleString('fr-FR') + ' €';
}
function wrapText(text, maxWidth, fontSize) {
const charW = fontSize * 0.58;
const maxChars = Math.max(1, Math.floor(maxWidth / charW));
const words = text.split(' ');
const lines = [];
let current = '';
for (const word of words) {
const candidate = current ? current + ' ' + word : word;
if (candidate.length <= maxChars) {
current = candidate;
} else {
if (current) lines.push(current);
current = word.length > maxChars ? word.slice(0, maxChars - 1) + '…' : word;
}
}
if (current) lines.push(current);
return lines.slice(0, 3);
}
/* ── Composant ──────────────────────────────────────────────── */
export default function InteretsDistributionChart({ rows, netMode }) {
const [hoveredIdx, setHoveredIdx] = useState(null);
const [tooltip, setTooltip] = useState(null);
const svgRef = useRef(null);
const wrapRef = useRef(null);
/* Intérêts cumulés par plateforme */
const data = useMemo(() => {
if (!rows?.length) return [];
const byPlat = {};
for (const r of rows) {
const key = r.plateforme_nom || `#${r.plateforme_id}`;
const val = netMode ? (r.interets_nets || 0) : (r.interets_bruts || 0);
byPlat[key] = (byPlat[key] || 0) + val;
}
return Object.entries(byPlat)
.filter(([, v]) => v > 0)
.sort(([, a], [, b]) => b - a)
.map(([name, value], i) => ({ name, value, color: PALETTE[i % PALETTE.length] }));
}, [rows, netMode]);
const total = data.reduce((s, i) => s + i.value, 0);
const W = 440, H = 290, GAP = 3;
const cells = useMemo(() => buildTreemap(data, 0, 0, W, H), [data]);
const handleMouseMove = useCallback((e, cell, idx) => {
if (!wrapRef.current) return;
const wrapRect = wrapRef.current.getBoundingClientRect();
setHoveredIdx(idx);
setTooltip({ x: e.clientX - wrapRect.left, y: e.clientY - wrapRect.top, cell });
}, []);
const handleMouseLeave = useCallback(() => {
setHoveredIdx(null);
setTooltip(null);
}, []);
if (!cells.length) return null;
const TIP_W = 160, TIP_H = 66;
const tipStyle = tooltip ? (() => {
const ww = wrapRef.current?.offsetWidth || 440;
const wh = wrapRef.current?.offsetHeight || 360;
let tx = tooltip.x + 14;
let ty = tooltip.y - TIP_H / 2;
if (tx + TIP_W > ww - 4) tx = tooltip.x - TIP_W - 14;
if (ty < 4) ty = 4;
if (ty + TIP_H > wh - 4) ty = wh - TIP_H - 4;
return { left: tx, top: ty };
})() : null;
const modeLabel = netMode ? 'Nets' : 'Bruts';
return (
<div className="dist-chart-wrap" ref={wrapRef}>
{/* ── En-tête ── */}
<div className="dist-chart-header">
<span className="dist-chart-title">Intérêts {modeLabel}</span>
<div className="dist-dropdown">
par Plateforme
<svg width="10" height="10" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9l6 6 6-6"/>
</svg>
</div>
</div>
{/* ── Treemap SVG ── */}
<div style={{ position: 'relative' }}>
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
style={{ width: '100%', height: 'auto', display: 'block' }}
onMouseLeave={handleMouseLeave}
>
<defs>
<linearGradient id="idt-shadow" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#000" stopOpacity="0" />
<stop offset="100%" stopColor="#000" stopOpacity="0.4" />
</linearGradient>
<filter id="idt-txt-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="1" stdDeviation="1.5" floodColor="#000" floodOpacity="0.55"/>
</filter>
{cells.map((cell, i) => {
const PAD = 4;
const gx = cell.x + GAP / 2 + PAD;
const gy = cell.y + GAP / 2 + PAD;
const gw = Math.max(cell.w - GAP - PAD * 2, 0);
const gh = Math.max(cell.h - GAP - PAD * 2, 0);
return (
<clipPath key={i} id={`idt-clip-${i}`}>
<rect x={gx} y={gy} width={gw} height={gh} />
</clipPath>
);
})}
</defs>
{cells.map((cell, i) => {
const gx = cell.x + GAP / 2;
const gy = cell.y + GAP / 2;
const gw = Math.max(cell.w - GAP, 0);
const gh = Math.max(cell.h - GAP, 0);
const cx = gx + gw / 2;
const cy = gy + gh / 2;
const pct = ((cell.value / total) * 100).toFixed(0);
const amt = fmtAmount(cell.value);
const isHovered = hoveredIdx === i;
const fsAmt = Math.min(15, Math.max(10, gw / 7.5));
const fsName = Math.min(12, Math.max(8, gw / 9.5));
const fsPct = Math.min(11, Math.max(7, gw / 11));
const lineH = 15;
const canShowAmt = gw > 36 && gh > 20;
const isLandscape = gw > gh;
const combinedText = `${cell.name}${pct} %`;
const combinedCharW = fsName * 0.58;
const combinedFits = isLandscape && (combinedText.length * combinedCharW) <= (gw - 10);
const nameLines = (gw > 40 && gh > 30) ? wrapText(cell.name, gw - 10, fsName) : [];
const canShowName = nameLines.length > 0;
const CLIP_PAD = 4;
const textHeightSoFar = (canShowAmt ? lineH : 0) + (canShowName ? nameLines.length * lineH : 0);
const canShowPct = !combinedFits && gw > 44 && (gh - CLIP_PAD * 2) >= textHeightSoFar + lineH;
const textItems = [];
{
const slots = [];
if (canShowAmt) slots.push({ type: 'amt', h: lineH + 2 });
if (combinedFits) {
slots.push({ type: 'combined', text: combinedText, h: lineH });
} else {
if (canShowName) nameLines.forEach((l, li) => slots.push({ type: 'name', li, text: l, h: lineH }));
if (canShowPct) slots.push({ type: 'pct', h: lineH });
}
const totalH = slots.reduce((s, sl) => s + sl.h, 0);
let y = cy - totalH / 2 + lineH / 2;
for (const sl of slots) {
if (sl.type === 'amt') textItems.push({ key: 'amt', text: amt, fs: fsAmt, fw: '700', op: 1, y });
if (sl.type === 'combined') textItems.push({ key: 'combined', text: sl.text, fs: fsName, fw: '500', op: 0.92, y });
if (sl.type === 'name') textItems.push({ key: `n${sl.li}`, text: sl.text, fs: fsName, fw: '500', op: 0.92, y });
if (sl.type === 'pct') textItems.push({ key: 'pct', text: pct + ' %', fs: fsPct, fw: '400', op: 0.75, y });
y += sl.h;
}
}
return (
<g key={i} style={{ cursor: 'pointer' }}
onMouseMove={e => handleMouseMove(e, { ...cell, pct, amt }, i)}
>
<rect x={gx} y={gy} width={gw} height={gh}
rx="5" fill={cell.color}
opacity={hoveredIdx !== null && !isHovered ? 0.45 : 0.9}
style={{ transition: 'opacity .15s' }}
/>
<rect x={gx} y={gy} width={gw} height={gh}
rx="5" fill="url(#idt-shadow)" opacity="0.35" />
{isHovered && (
<rect x={gx} y={gy} width={gw} height={gh}
rx="5" fill="none"
stroke="rgba(255,255,255,0.55)" strokeWidth="1.5" />
)}
<g clipPath={`url(#idt-clip-${i})`}>
{textItems.map(l => (
<text key={l.key} x={cx} y={l.y}
textAnchor="middle" dominantBaseline="middle"
fill="white"
fillOpacity={hoveredIdx !== null && !isHovered ? 0.3 : l.op}
fontSize={l.fs} fontWeight={l.fw}
fontFamily="system-ui,-apple-system,sans-serif"
filter="url(#idt-txt-shadow)"
style={{ transition: 'fill-opacity .15s' }}>
{l.text}
</text>
))}
</g>
</g>
);
})}
</svg>
{/* ── Tooltip ── */}
{tooltip && tipStyle && (
<div className="dist-tooltip" style={{ left: tipStyle.left, top: tipStyle.top }}>
<div className="dist-tooltip-dot" style={{ background: tooltip.cell.color }} />
<div>
<div className="dist-tooltip-amt">{tooltip.cell.amt}</div>
<div className="dist-tooltip-name">{tooltip.cell.name}</div>
<div className="dist-tooltip-pct">{tooltip.cell.pct} %</div>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,456 @@
import { useMemo, useState, useRef } from 'react';
import { useInteretsChart } from '../context/InteretsChartContext.jsx';
function fmtTotal(v) {
return v.toLocaleString('fr-FR',{minimumFractionDigits:2,maximumFractionDigits:2})+' €';
}
function fmtCenter(v) {
if (!v||v===0) return '—';
return v.toLocaleString('fr-FR',{minimumFractionDigits:2,maximumFractionDigits:2})+' €';
}
/* ── Helpers SVG ──────────────────────────────────────────────── */
function polar(cx,cy,r,deg) {
const rad=(deg-90)*Math.PI/180;
return {x:cx+r*Math.cos(rad), y:cy+r*Math.sin(rad)};
}
function roundedArcPath(cx, cy, outerR, innerR, startDeg, endDeg) {
const span = endDeg - startDeg;
if (span < 0.1) return '';
const f = n => n.toFixed(2);
if (span >= 359.9) {
const o0=polar(cx,cy,outerR,0), o1=polar(cx,cy,outerR,180);
const i0=polar(cx,cy,innerR,0), i1=polar(cx,cy,innerR,180);
return [
`M${f(o0.x)} ${f(o0.y)} A${outerR} ${outerR} 0 1 1 ${f(o1.x)} ${f(o1.y)}`,
`A${outerR} ${outerR} 0 1 1 ${f(o0.x)} ${f(o0.y)} Z`,
`M${f(i0.x)} ${f(i0.y)} A${innerR} ${innerR} 0 1 1 ${f(i1.x)} ${f(i1.y)}`,
`A${innerR} ${innerR} 0 1 1 ${f(i0.x)} ${f(i0.y)} Z`,
].join(' ');
}
const capR = (outerR - innerR) / 2;
const lg = span > 180 ? 1 : 0;
const oS=polar(cx,cy,outerR,startDeg), oE=polar(cx,cy,outerR,endDeg);
const iS=polar(cx,cy,innerR,startDeg), iE=polar(cx,cy,innerR,endDeg);
return [
`M${f(oS.x)} ${f(oS.y)}`,
`A${outerR} ${outerR} 0 ${lg} 1 ${f(oE.x)} ${f(oE.y)}`,
`A${capR} ${capR} 0 0 1 ${f(iE.x)} ${f(iE.y)}`,
`A${innerR} ${innerR} 0 ${lg} 0 ${f(iS.x)} ${f(iS.y)}`,
`A${capR} ${capR} 0 0 0 ${f(oS.x)} ${f(oS.y)}`,
'Z',
].join(' ');
}
/* ── Dimensions donut ─────────────────────────────────────────── */
const CX=120, CY=120;
const OUTER_R=108, INNER_R=72;
export default function InteretsDonutChart() {
const {
annee,
inclureInterets, setInclureInterets,
inclureCapital, setInclureCapital,
inclureCashback, setInclureCashback,
selectedMonth, setSelectedMonth,
showActual, showProjected,
selectActualOnly, selectProjectedOnly, setActualProjected,
months,
modeGlobal,
selectedYear, setSelectedYear,
years,
chartInterets, chartCapital, chartCashback,
netMode,
} = useInteretsChart();
const [hoveredArcIdx, setHoveredArcIdx] = useState(null);
const [hoveredLegIdx, setHoveredLegIdx] = useState(null);
const [centerHovered, setCenterHovered] = useState(false);
/* ── Mémoire de filtrage ─────────────────────────────────────── */
const prevStateRef = useRef(null);
const [hasPrev, setHasPrev] = useState(false);
const activeCount = [inclureCapital, inclureCashback, inclureInterets].filter(Boolean).length;
/* ── Construire un item donut ────────────────────────────────── */
const makeItem = (label, color, opacity, actualAmt, projectedAmt) => ({
label, color, opacity,
value: actualAmt + projectedAmt,
actualAmt, projectedAmt,
});
/* ── Source de données — ordre fixe : Intérêts → Capital → Cashback ── */
const donutData = useMemo(() => {
if (activeCount===0) return [];
if (modeGlobal) {
const src = selectedYear !== null ? years.find(y => y.y === selectedYear) : null;
const sumA = key => showActual ? (src ? (src[key]||0) : years.reduce((s,y) => s+(y[key]||0), 0)) : 0;
const sumP = key => showProjected ? (src ? (src[key]||0) : years.reduce((s,y) => s+(y[key]||0), 0)) : 0;
if (activeCount===1) {
if (inclureInterets) {
const actual=sumA('interetsAmt'), proj=sumP('interetsProjAmt'), s=[];
if(actual>0) s.push(makeItem('Reçu', chartInterets, 0.88, actual, 0));
if(proj >0) s.push(makeItem('Projeté', chartInterets, 0.30, proj, 0));
return s;
}
if (inclureCapital) {
const actual=sumA('capitalAmt'), proj=sumP('capitalProjAmt'), s=[];
if(actual>0) s.push(makeItem('Reçu', chartCapital, 0.88, actual, 0));
if(proj >0) s.push(makeItem('Projeté', chartCapital, 0.30, proj, 0));
return s;
}
if (inclureCashback) {
const v=sumA('cashbackAmt');
return v>0?[makeItem('Reçu', chartCashback, 0.88, v, 0)]:[];
}
}
// Multi-types — ordre : Intérêts → Capital → Cashback
const s=[];
if(inclureInterets){
const a=sumA('interetsAmt'), p=sumP('interetsProjAmt');
if(a+p>0) s.push(makeItem(netMode?'Intérêts nets':'Intérêts bruts', chartInterets, 0.88, a, p));
}
if(inclureCapital){
const a=sumA('capitalAmt'), p=sumP('capitalProjAmt');
if(a+p>0) s.push(makeItem('Capital', chartCapital, 0.88, a, p));
}
if(inclureCashback){
const a=sumA('cashbackAmt');
if(a>0) s.push(makeItem('Cashback', chartCashback, 0.88, a, 0));
}
return s;
}
// Mode mensuel
const src=selectedMonth!==null?months[selectedMonth]:null;
const sumA=key=>showActual ?(src?(src[key]||0):months.reduce((s,m)=>s+(m[key]||0),0)):0;
const sumP=key=>showProjected ?(src?(src[key]||0):months.reduce((s,m)=>s+(m[key]||0),0)):0;
if (activeCount===1) {
if (inclureInterets) {
const actual=sumA('interetsAmt'), proj=sumP('interetsProjAmt'), s=[];
if(actual>0) s.push(makeItem('Reçu', chartInterets, 0.88, actual, 0));
if(proj >0) s.push(makeItem('Projeté', chartInterets, 0.30, proj, 0));
return s;
}
if (inclureCapital) {
const actual=sumA('capitalAmt'), proj=sumP('capitalProjAmt'), s=[];
if(actual>0) s.push(makeItem('Reçu', chartCapital, 0.88, actual, 0));
if(proj >0) s.push(makeItem('Projeté', chartCapital, 0.30, proj, 0));
return s;
}
if (inclureCashback) {
const v=sumA('cashbackAmt');
return v>0?[makeItem('Reçu', chartCashback, 0.88, v, 0)]:[];
}
}
// Multi-types — ordre : Intérêts → Capital → Cashback
const s=[];
if(inclureInterets){
const a=sumA('interetsAmt'), p=sumP('interetsProjAmt');
if(a+p>0) s.push(makeItem(netMode?'Intérêts nets':'Intérêts bruts', chartInterets, 0.88, a, p));
}
if(inclureCapital){
const a=sumA('capitalAmt'), p=sumP('capitalProjAmt');
if(a+p>0) s.push(makeItem('Capital', chartCapital, 0.88, a, p));
}
if(inclureCashback){
const a=sumA('cashbackAmt');
if(a>0) s.push(makeItem('Cashback', chartCashback, 0.88, a, 0));
}
return s;
},[months,selectedMonth,inclureCapital,inclureCashback,inclureInterets,
showActual,showProjected,chartCapital,chartCashback,chartInterets,
modeGlobal,years,selectedYear,activeCount]);
const total=donutData.reduce((s,d)=>s+d.value,0);
const GAP = donutData.length <= 1 ? 0 : 4;
const arcs = useMemo(() => {
const out=[]; let cur=0;
donutData.forEach(d => {
const sweep = total > 0 ? (d.value / total) * 360 : 0;
const s = cur + GAP/2, e = cur + sweep - GAP/2;
if (e - s > 0.1) out.push({...d, startDeg:s, endDeg:e});
cur += sweep;
});
return out;
},[donutData,total,GAP]);
/* ── Clic sur un quartier ou une ligne de légende ───────────── */
const handleArcClick = (item) => {
prevStateRef.current = {
inclureInterets, inclureCapital, inclureCashback,
showActual, showProjected,
};
setHasPrev(true);
if (activeCount > 1) {
setInclureCapital(item.label === 'Capital');
setInclureCashback(item.label === 'Cashback');
setInclureInterets(item.label === 'Intérêts nets' || item.label === 'Intérêts bruts');
} else {
if (item.label === 'Reçu') selectActualOnly();
if (item.label === 'Projeté') selectProjectedOnly();
}
};
/* ── Retour arrière via clic centre ──────────────────────────── */
const handleCenterClick = () => {
if (!hasPrev || !prevStateRef.current) return;
const ps = prevStateRef.current;
setInclureCapital(ps.inclureCapital);
setInclureCashback(ps.inclureCashback);
setInclureInterets(ps.inclureInterets);
setActualProjected(ps.showActual, ps.showProjected);
prevStateRef.current = null;
setHasPrev(false);
};
/* ── Tooltip positionné sur le point extérieur de l'arc ────── */
const tooltipArc = hoveredArcIdx !== null ? arcs[hoveredArcIdx] : null;
const tooltipPos = tooltipArc ? (() => {
const midAngle = (tooltipArc.startDeg + tooltipArc.endDeg) / 2;
const tipPt = polar(CX, CY, OUTER_R + 12, midAngle);
return {
pctX: (tipPt.x / 240) * 100,
pctY: (tipPt.y / 240) * 100,
onRight: tipPt.x >= CX,
onBottom: tipPt.y >= CY,
};
})() : null;
/* ── Sous-détail reçu/projeté pour la légende ───────────────── */
const getLegendDetail = (d) => {
if (activeCount <= 1) return null;
if (d.actualAmt > 0 && d.projectedAmt > 0) {
if (d.label !== 'Intérêts nets' && d.label !== 'Intérêts bruts' && d.label !== 'Capital') return null;
return `dont ${fmtTotal(d.actualAmt)} reçus · ${fmtTotal(d.projectedAmt)} projetés`;
}
return null;
};
/* ── Labels / sélection ──────────────────────────────────────── */
const centerLabel = modeGlobal
? (selectedYear !== null ? String(selectedYear) : 'Total')
: (selectedMonth !== null ? months[selectedMonth].label : 'Total');
const headerSub = modeGlobal
? (selectedYear !== null ? String(selectedYear) : 'Toutes les années')
: (selectedMonth !== null ? `${months[selectedMonth].labelLong} ${annee}` : String(annee));
const hasSelection = modeGlobal ? selectedYear !== null : selectedMonth !== null;
const clearSelection = () => {
if (modeGlobal) setSelectedYear(null);
else setSelectedMonth(null);
};
return (
<div className="solde-chart-wrap" style={{
padding:'20px 20px 16px',
height:'100%', boxSizing:'border-box',
display:'flex', flexDirection:'column',
}}>
{/* ── SVG donut ── */}
<div style={{flex:1,display:'flex',alignItems:'center',justifyContent:'center',minHeight:0,padding:'4px 0'}}>
<div style={{position:'relative',width:'100%',maxWidth:260}}
onMouseLeave={()=>{ setHoveredArcIdx(null); setCenterHovered(false); }}
>
<svg viewBox="0 0 240 240" style={{width:'100%',height:'auto',display:'block'}}>
{/* Anneau de fond */}
<circle cx={CX} cy={CY} r={(OUTER_R+INNER_R)/2} fill="none"
stroke="var(--border)" strokeWidth={OUTER_R-INNER_R} opacity={0.18}/>
{/* Segments */}
{total>0 && arcs.map((arc,i)=>(
<path key={i}
d={roundedArcPath(CX,CY,OUTER_R,INNER_R,arc.startDeg,arc.endDeg)}
fill={arc.color}
fillRule={arc.endDeg-arc.startDeg>=359.9?'evenodd':undefined}
opacity={hoveredArcIdx===i
? Math.min(1,(arc.opacity??0.88)+0.12)
: (arc.opacity??0.88)}
style={{cursor:'pointer',transition:'opacity .12s'}}
onMouseEnter={()=>setHoveredArcIdx(i)}
onClick={()=>handleArcClick(arc)}
/>
))}
{/* Zone cliquable centre */}
<circle cx={CX} cy={CY} r={INNER_R - 2}
fill={centerHovered && hasPrev ? 'var(--primary)' : 'transparent'}
fillOpacity={centerHovered && hasPrev ? 0.07 : 0}
style={{ cursor: hasPrev ? 'pointer' : 'default', transition:'fill-opacity .15s' }}
onMouseEnter={()=>setCenterHovered(true)}
onMouseLeave={()=>setCenterHovered(false)}
onClick={handleCenterClick}
/>
{/* Indicateur retour arrière */}
{hasPrev && (
<text x={CX} y={CY-22} textAnchor="middle" fontSize="13"
fill="var(--primary)" fontFamily="system-ui,sans-serif"
opacity={centerHovered ? 1 : 0.55}
style={{transition:'opacity .15s', pointerEvents:'none'}}>
</text>
)}
{/* Texte central */}
<text x={CX} y={hasPrev ? CY-4 : CY-9}
textAnchor="middle" fontSize="12"
fill={hasPrev && centerHovered ? 'var(--primary)' : 'var(--text-muted)'}
fontFamily="system-ui,sans-serif" fontWeight="400"
style={{transition:'fill .15s', pointerEvents:'none'}}>
{centerLabel}
</text>
<text x={CX} y={hasPrev ? CY+14 : CY+13}
textAnchor="middle" fontSize="18"
fill={hasPrev && centerHovered ? 'var(--primary)' : 'var(--text)'}
fontWeight="700" fontFamily="system-ui,sans-serif"
style={{transition:'fill .15s', pointerEvents:'none'}}>
{fmtCenter(total)}
</text>
</svg>
{/* ── Tooltip arc ── */}
{tooltipArc && tooltipPos && (
<div style={{
position:'absolute',
...(tooltipPos.onRight
? { left:`${tooltipPos.pctX}%`, transform:'translateX(8px)' }
: { right:`${100-tooltipPos.pctX}%`, transform:'translateX(-8px)' }),
...(tooltipPos.onBottom
? { top:`${tooltipPos.pctY}%` }
: { bottom:`${100-tooltipPos.pctY}%` }),
pointerEvents:'none', zIndex:30,
}}>
<div className="sg-tooltip">
<span className="sg-tooltip-date" style={{display:'flex',alignItems:'center',gap:6}}>
<span style={{display:'inline-block',width:8,height:8,borderRadius:2,
background:tooltipArc.color,opacity:tooltipArc.opacity??0.88,flexShrink:0}}/>
{tooltipArc.label}
</span>
<span style={{fontSize:'var(--fs-sm)',color:'var(--text)',fontWeight:700}}>
{fmtTotal(tooltipArc.value)}
</span>
<span style={{fontSize:'var(--fs-xs)',color:'var(--text-muted)'}}>
{total>0 ? ((tooltipArc.value/total)*100).toFixed(1)+' %' : '—'}
</span>
{(activeCount > 1 || tooltipArc.label === 'Reçu' || tooltipArc.label === 'Projeté') && (
<span style={{
fontSize:'var(--fs-xs)',color:'var(--text-muted)',opacity:0.6,
marginTop:2,borderTop:'1px solid var(--border)',paddingTop:4,
}}>
Cliquer pour {activeCount > 1 ? 'isoler' : 'sélectionner'}
</span>
)}
</div>
</div>
)}
{/* ── Tooltip centre ── */}
{centerHovered && hasPrev && (
<div style={{
position:'absolute', left:'50%', top:'50%',
transform:'translate(-50%, calc(-100% - 10px))',
pointerEvents:'none', zIndex:30,
}}>
<div className="sg-tooltip" style={{textAlign:'center'}}>
<span style={{fontSize:'var(--fs-xs)',color:'var(--primary)',fontWeight:600}}>
Restaurer le filtrage précédent
</span>
</div>
</div>
)}
</div>
</div>
{/* ── Légende ── */}
<div style={{marginTop:4}}>
<div style={{
display:'flex', alignItems:'center', justifyContent:'space-between',
marginBottom:10, paddingBottom:10,
borderBottom:'1px solid var(--border)',
}}>
<span style={{fontSize:'var(--fs-sm)',fontWeight:600,color:'var(--text)'}}>Répartition</span>
<span
style={{
fontSize:'var(--fs-xs)',
color:hasSelection?'var(--primary)':'var(--text-muted)',
cursor:hasSelection?'pointer':'default',
display:'inline-flex', alignItems:'center', gap:4,
}}
onClick={hasSelection?()=>clearSelection():undefined}
title={hasSelection?'Revenir à la vue globale':undefined}
>
{headerSub}
{hasSelection&&<span style={{fontSize:11,opacity:0.7}}>×</span>}
</span>
</div>
{donutData.length===0 && (
<div style={{fontSize:'var(--fs-xs)',color:'var(--text-muted)',textAlign:'center',padding:'10px 0'}}>
Aucun type sélectionné
</div>
)}
{donutData.map((d,i)=>{
const detail = getLegendDetail(d);
const isClickable = activeCount > 1 || d.label === 'Reçu' || d.label === 'Projeté';
const isHov = hoveredLegIdx === i;
return (
<div key={i}
onClick={isClickable ? ()=>handleArcClick(d) : undefined}
onMouseEnter={isClickable ? ()=>setHoveredLegIdx(i) : undefined}
onMouseLeave={isClickable ? ()=>setHoveredLegIdx(null) : undefined}
style={{
display:'flex', alignItems:'center', gap:8,
padding:'7px 6px',
marginLeft:-6, marginRight:-6,
borderTop: i>0 ? '1px solid var(--border)' : 'none',
cursor: isClickable ? 'pointer' : 'default',
borderRadius:6,
background: isHov ? 'var(--surface-2)' : 'transparent',
transition:'background .12s',
}}
>
<span style={{
width:10,height:10,borderRadius:2,flexShrink:0,
background:d.color,opacity:d.opacity??0.88,
}}/>
<div style={{flex:1,minWidth:0,display:'flex',alignItems:'baseline',gap:6,flexWrap:'wrap'}}>
<span style={{fontSize:'var(--fs-xs)',color:'var(--text-muted)',fontWeight:400,whiteSpace:'nowrap'}}>
{d.label}
</span>
<span style={{fontSize:'var(--fs-sm)',color:'var(--text)',fontWeight:700,whiteSpace:'nowrap'}}>
{fmtTotal(d.value)}
</span>
{detail && (
<span style={{fontSize:11,color:'var(--text-muted)',opacity:0.8,lineHeight:1.3}}>
{detail}
</span>
)}
</div>
<span style={{fontSize:'var(--fs-xs)',color:'var(--text)',fontWeight:500,flexShrink:0,minWidth:38,textAlign:'right'}}>
{total>0?((d.value/total)*100).toFixed(1)+' %':'—'}
</span>
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,542 @@
import { useState, useRef, useEffect } from 'react';
import { api } from '../api.js';
import { useInteretsChart } from '../context/InteretsChartContext.jsx';
const ICONS_BASE = '/api/icons-files/';
const GRID = 'rgba(255,255,255,0.055)';
const LABEL = '#4a5568';
const MOIS = ['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc'];
function fmtShort(v) {
if (!v || v === 0) return '';
const abs = Math.abs(v);
if (abs >= 1000) return (v/1000).toLocaleString('fr-FR',{maximumFractionDigits:1})+'k €';
return v.toLocaleString('fr-FR',{minimumFractionDigits:1,maximumFractionDigits:1})+' €';
}
function fmtTotal(v) {
return v.toLocaleString('fr-FR',{minimumFractionDigits:2,maximumFractionDigits:2})+' €';
}
function hexToRgba(hex, a) {
if (!hex||hex.length<7) return `rgba(79,168,232,${a})`;
const r=parseInt(hex.slice(1,3),16), g=parseInt(hex.slice(3,5),16), b=parseInt(hex.slice(5,7),16);
return `rgba(${r},${g},${b},${a})`;
}
export default function InteretsMensuelsChart() {
const {
annee, setAnnee, availableYears,
inclureInterets, setInclureInterets,
inclureCapital, setInclureCapital,
inclureCashback, setInclureCashback,
selectedMonth, setSelectedMonth,
showActual, toggleActual,
showProjected, toggleProjected,
months, annualTotal,
// Mode global (TOUT)
modeGlobal, toggleModeGlobal,
selectedYear, setSelectedYear,
years, globalTotal,
netMode,
chartInterets, chartCapital, chartCashback,
} = useInteretsChart();
// État local uniquement
const [hovered, setHovered] = useState(null);
const [windowStart, setWindowStart] = useState(0);
const [libIcons, setLibIcons] = useState({});
const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.innerWidth < 768);
const [sheetOpen, setSheetOpen] = useState(false);
const initializedRef = useRef(false);
useEffect(() => {
api.get('/icons').then(rows => {
const m = {};
rows.forEach(r => { m[r.name] = r.filename; });
setLibIcons(m);
}).catch(() => {});
}, []);
useEffect(() => {
const onResize = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
// Réinitialiser le hovered au changement de mode
useEffect(() => { setHovered(null); }, [modeGlobal]);
const visibleYears = availableYears.length ? availableYears.slice(windowStart, windowStart+3) : [annee];
const canPrev = windowStart > 0;
const canNext = windowStart + 3 < availableYears.length;
useEffect(() => {
if (!availableYears.length || initializedRef.current) return;
initializedRef.current = true;
const idx = availableYears.indexOf(annee);
const safe = idx >= 0 ? idx : availableYears.length - 1;
setWindowStart(Math.max(0, Math.min(availableYears.length - 3, safe - 1)));
}, [availableYears]);
/* ── Données affichées selon le mode ── */
const items = modeGlobal ? years : months;
const barCount = items.length;
/* ── SVG layout ── */
const W = 900, H = 280;
const PAD = { top: 52, right: 20, bottom: 34, left: 70 };
const plotW = W - PAD.left - PAD.right;
const plotH = H - PAD.top - PAD.bottom;
const gap = modeGlobal ? 10 : 8;
const barW = barCount > 0 ? Math.floor((plotW - gap * (barCount - 1)) / barCount) : 60;
const barBotY = PAD.top + plotH;
const filteredTotal = item =>
(showActual ? item.capitalAmt + item.cashbackAmt + item.interetsAmt : 0) +
(showProjected ? item.capitalProjAmt + item.interetsProjAmt : 0);
const filteredSum = items.reduce((s, item) => s + filteredTotal(item), 0);
const rawMax = Math.max(...items.map(item => filteredTotal(item)), 0.01);
const niceStep = (() => {
const raw = rawMax/4, mag = Math.pow(10,Math.floor(Math.log10(raw))), n = raw/mag;
return (n<1.5?1:n<3.5?2:n<7.5?5:10)*mag;
})();
const niceMax = Math.ceil(rawMax/niceStep)*niceStep || niceStep;
const yScale = v => PAD.top + plotH - (v/niceMax)*plotH;
const barX = i => PAD.left + i*(barW+gap);
const yTicks = Array.from({length: Math.round(niceMax/niceStep)+1}, (_,i) => ({v:i*niceStep, y:yScale(i*niceStep)}));
/* ── Tooltip ── */
const tooltipItem = hovered !== null ? items[hovered] : null;
const tooltipBCX = hovered !== null ? barX(hovered) + barW/2 : 0;
const tooltipBTY = hovered !== null ? yScale(filteredTotal(items[hovered])) : 0;
const anchorRight = tooltipBCX / W > 0.65;
const buildSegments = (item) => {
const actual = [];
if (showActual) {
if (inclureCapital && item.capitalAmt > 0) actual.push({color:chartCapital, v:item.capitalAmt});
if (inclureCashback && item.cashbackAmt > 0) actual.push({color:chartCashback, v:item.cashbackAmt});
if (inclureInterets && item.interetsAmt > 0) actual.push({color:chartInterets, v:item.interetsAmt});
}
const projected = [];
if (showProjected) {
if (inclureCapital && item.capitalProjAmt > 0) projected.push({color:chartCapital, v:item.capitalProjAmt});
if (inclureInterets && item.interetsProjAmt > 0) projected.push({color:chartInterets, v:item.interetsProjAmt});
}
return { actual, projected };
};
const activeTypes = [
inclureCapital && {key:'capital', color:chartCapital, label:'Capital'},
inclureCashback && {key:'cashback', color:chartCashback, label:'Cashback'},
inclureInterets && {key:'interets', color:chartInterets, label:netMode?'Intérêts nets':'Intérêts bruts'},
].filter(Boolean);
const activeTypeCount = [inclureInterets, inclureCapital, inclureCashback].filter(Boolean).length;
const AppIcon = ({ name, size = 28, active = false }) => {
const filename = libIcons[name];
if (filename) return (
<img src={ICONS_BASE + filename} className="app-lib-icon" width={size} height={size}
aria-hidden="true"
style={{ opacity: active ? 1 : 0.35, display: 'block', transition: 'opacity .15s' }} />
);
return <span style={{ width: size, height: size, display: 'block',
borderRadius: 4, background: 'var(--text-muted)',
opacity: active ? 0.55 : 0.2, transition: 'opacity .15s' }} />;
};
/* ── Label en-tête ── */
return (
<div className="solde-chart-wrap" style={{padding:'24px 24px 16px', height:'100%', boxSizing:'border-box'}}>
{/* ── En-tête ── */}
<div className="solde-chart-header">
<div className="solde-chart-info">
<div style={{display:'flex',alignItems:'center',gap:5,flexWrap:'wrap',marginBottom:2}}>
{inclureInterets && (
<span style={{display:'inline-flex',alignItems:'center',gap:4,background:hexToRgba(chartInterets,0.12),borderRadius:5,padding:'3px 8px'}}>
<span style={{width:7,height:7,borderRadius:2,background:chartInterets,flexShrink:0}}/>
<span style={{fontSize:13,color:chartInterets,fontWeight:600}}>{netMode?'Intérêts nets':'Intérêts bruts'}</span>
</span>
)}
{inclureCapital && (
<span style={{display:'inline-flex',alignItems:'center',gap:4,background:hexToRgba(chartCapital,0.12),borderRadius:5,padding:'3px 8px'}}>
<span style={{width:7,height:7,borderRadius:2,background:chartCapital,flexShrink:0}}/>
<span style={{fontSize:13,color:chartCapital,fontWeight:600}}>Capital</span>
</span>
)}
{inclureCashback && (
<span style={{display:'inline-flex',alignItems:'center',gap:4,background:hexToRgba(chartCashback,0.12),borderRadius:5,padding:'3px 8px'}}>
<span style={{width:7,height:7,borderRadius:2,background:chartCashback,flexShrink:0}}/>
<span style={{fontSize:13,color:chartCashback,fontWeight:600}}>Cashback</span>
</span>
)}
{!inclureInterets && !inclureCapital && !inclureCashback && (
<span style={{fontSize:13,color:'var(--text-muted)'}}></span>
)}
<span style={{fontSize:13,color:'var(--text-muted)'}}>· {modeGlobal?'Toutes les années':annee}</span>
</div>
<div className="solde-chart-value">{fmtTotal(filteredSum)}</div>
</div>
{isMobile ? (
/* ── Mobile : bouton Filtres ── */
<button
onClick={()=>setSheetOpen(true)}
style={{
display:'flex', alignItems:'center', gap:7,
background:'var(--surface-2)', border:'1px solid var(--border)',
borderRadius:20, padding:'6px 14px', cursor:'pointer',
fontSize:'var(--fs-sm)', fontWeight:600, color:'var(--text)',
}}>
Filtres
{activeTypeCount > 0 && (
<span style={{
background:'var(--primary)', color:'#fff',
borderRadius:10, padding:'1px 7px',
fontSize:11, fontWeight:700,
}}>{activeTypeCount}</span>
)}
</button>
) : (
/* ── Desktop : controls existants ── */
<div className="solde-chart-controls">
<button
title={inclureInterets?'Intérêts inclus — cliquer pour exclure':'Cliquer pour inclure les intérêts'}
onClick={()=>setInclureInterets(v=>!v)}
style={{background:inclureInterets?hexToRgba(chartInterets,0.13):'none', border:'1px solid '+(inclureInterets?chartInterets:'transparent'), borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center', transition:'background .15s,border-color .15s', marginRight:2}}>
<AppIcon name="interets" active={inclureInterets} />
</button>
<button
title={inclureCapital?'Capital inclus — cliquer pour exclure':'Cliquer pour inclure le capital'}
onClick={()=>setInclureCapital(v=>!v)}
style={{background:inclureCapital?hexToRgba(chartCapital,0.13):'none', border:'1px solid '+(inclureCapital?chartCapital:'transparent'), borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center', transition:'background .15s,border-color .15s', marginRight:2}}>
<AppIcon name="capital" active={inclureCapital} />
</button>
<button
title={inclureCashback?'Cashback inclus — cliquer pour exclure':'Cliquer pour inclure le cashback'}
onClick={()=>setInclureCashback(v=>!v)}
style={{background:inclureCashback?hexToRgba(chartCashback,0.13):'none', border:'1px solid '+(inclureCashback?chartCashback:'transparent'), borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center', transition:'background .15s,border-color .15s', marginRight:2}}>
<AppIcon name="cashback" active={inclureCashback} />
</button>
<div className="solde-chart-ranges">
{!modeGlobal && <>
<button className="solde-range-btn" onClick={()=>setWindowStart(w=>Math.max(0,w-1))} disabled={!canPrev} style={{opacity:canPrev?1:0.3}}></button>
{visibleYears.map(y=>(
<button key={y} className={`solde-range-btn${annee===y?' active':''}`}
onClick={()=>{ setAnnee(y); }}>
{y}
</button>
))}
<button className="solde-range-btn" onClick={()=>setWindowStart(w=>Math.min(Math.max(0,availableYears.length-3),w+1))} disabled={!canNext} style={{opacity:canNext?1:0.3}}></button>
</>}
<button className={`solde-range-btn${modeGlobal?' active':''}`} onClick={()=>toggleModeGlobal()}>
TOUT
</button>
</div>
</div>
)}
</div>
{/* ── SVG bar chart ── */}
<div style={{position:'relative', userSelect:'none'}}>
<svg
viewBox={`0 0 ${W} ${H}`}
style={{width:'100%', height:'auto', display:'block'}}
onMouseLeave={()=>setHovered(null)}
>
{yTicks.map(({v,y},i)=>(
<g key={i}>
<line x1={PAD.left} y1={y} x2={W-PAD.right} y2={y} stroke={GRID} strokeWidth="1" strokeDasharray={i===0?'':'3 5'}/>
<text x={PAD.left-10} y={y+4} textAnchor="end" fill={LABEL} fontSize="11" fontFamily="system-ui,sans-serif">{v===0?'':fmtShort(v)}</text>
</g>
))}
{items.map((item,i)=>{
const x=barX(i), isHov=hovered===i;
// Sélection : mois en mode mensuel, année en mode global
const isSel = modeGlobal ? (selectedYear===item.y) : (selectedMonth===i);
const dimmed = modeGlobal
? (selectedYear!==null && !isSel)
: (selectedMonth!==null && !isSel);
const {actual:aSegs,projected:pSegs}=buildSegments(item);
let cumH=0;
const aRects=aSegs.map(s=>{const h=(s.v/niceMax)*plotH; const r={color:s.color,x,y:barBotY-cumH-h,w:barW,h,isP:false}; cumH+=h; return r;});
const pRects=pSegs.map(s=>{const h=(s.v/niceMax)*plotH; const r={color:s.color,x,y:barBotY-cumH-h,w:barW,h,isP:true}; cumH+=h; return r;});
const all=[...aRects,...pRects];
const totalH=(filteredTotal(item)/niceMax)*plotH;
const topColor=all.length>0?all[all.length-1].color:LABEL;
const isCurrentMark = modeGlobal ? item.isCurrent : item.isCurrentMonth;
return (
<g key={i}
onMouseEnter={()=>setHovered(i)}
onClick={()=>{
if (modeGlobal) {
setSelectedYear(prev => prev===item.y ? null : item.y);
} else {
setSelectedMonth(prev => prev===i ? null : i);
}
}}
style={{cursor:'pointer', opacity:dimmed?0.35:1, transition:'opacity .15s'}}
>
{all.map((r,ri)=>(
<rect key={ri} x={r.x} y={r.y} width={r.w} height={r.h}
fill={r.color}
fillOpacity={r.isP?(isHov||isSel?0.48:0.28):(isHov||isSel?1.0:0.82)}
rx={ri===all.length-1?2:0} ry={ri===all.length-1?2:0}
/>
))}
{filteredTotal(item)>0&&(
<text x={x+barW/2} y={barBotY-totalH-5} textAnchor="middle"
fill={isHov||isSel?topColor:LABEL} fontSize="9" fontFamily="system-ui,sans-serif"
fontWeight={isHov||isSel?'700':undefined}>
{fmtShort(filteredTotal(item))}
</text>
)}
<text x={x+barW/2} y={H-10} textAnchor="middle"
fill={isCurrentMark||isSel?topColor:LABEL}
fontSize="10" fontFamily="system-ui,sans-serif"
fontWeight={isCurrentMark||isSel?'700':undefined}>
{item.label}
</text>
{isSel&&<rect x={x+barW/2-7} y={H-3} width={14} height={3} rx={1.5} fill={topColor} fillOpacity={0.9}/>}
</g>
);
})}
</svg>
{/* Tooltip */}
{tooltipItem&&tooltipItem.total>0&&(
<div style={{position:'absolute', ...(anchorRight?{right:`calc(${(1-tooltipBCX/W)*100}% + 8px)`}:{left:`calc(${(tooltipBCX/W)*100}% + 8px)`}), top:`calc(${(tooltipBTY/H)*100}% - 8px)`, transform:'translateY(-100%)', pointerEvents:'none', zIndex:20}}>
<div className="sg-tooltip">
<span className="sg-tooltip-date">
{modeGlobal
? `${tooltipItem.label}${tooltipItem.isCurrent?' · année en cours':''}`
: `${MOIS[tooltipItem.m-1]} ${annee}${tooltipItem.isCurrentMonth?' · mois en cours':''}`
}
</span>
{(()=>{
const ROW=({label,value,color=null,indent=false,muted=false})=>(
<span style={{fontSize:'var(--fs-xs)',color:'var(--text-muted)',display:'flex',justifyContent:'space-between',gap:16,alignItems:'center'}}>
{color&&<span style={{display:'inline-block',width:8,height:8,borderRadius:2,background:color,flexShrink:0}}/>}
<span style={{paddingLeft:(!color&&indent)?8:0,flex:1}}>{label}</span>
<span style={{color:muted?undefined:'var(--text)',fontWeight:muted?undefined:600}}>{value}</span>
</span>
);
const hasMix=tooltipItem.actual>0&&tooltipItem.projected>0;
const multi=[inclureCapital,inclureCashback,inclureInterets].filter(Boolean).length>1;
return(<>
{tooltipItem.actual>0&&<>
<ROW label="Reçu" value={fmtTotal(tooltipItem.actual)}/>
{multi&&inclureCapital &&tooltipItem.capitalAmt >0&&<ROW label="Capital" value={fmtTotal(tooltipItem.capitalAmt)} color={chartCapital} indent muted/>}
{multi&&inclureCashback&&tooltipItem.cashbackAmt>0&&<ROW label="Cashback" value={fmtTotal(tooltipItem.cashbackAmt)} color={chartCashback} indent muted/>}
{multi&&inclureInterets&&tooltipItem.interetsAmt>0&&<ROW label={`Intérêts (${netMode?'nets':'bruts'})`} value={fmtTotal(tooltipItem.interetsAmt)} color={chartInterets} indent muted/>}
{!multi&&inclureInterets&&<ROW label={`dont intérêts (${netMode?'nets':'bruts'})`} value={fmtTotal(tooltipItem.interetsAmt)} indent muted/>}
{!multi&&inclureCapital &&<ROW label="dont capital" value={fmtTotal(tooltipItem.capitalAmt)} indent muted/>}
{!multi&&inclureCashback&&<ROW label="dont cashback" value={fmtTotal(tooltipItem.cashbackAmt)} indent muted/>}
</>}
{tooltipItem.projected>0&&<>
<ROW label="Projeté" value={fmtTotal(tooltipItem.projected)}/>
{multi&&inclureCapital &&tooltipItem.capitalProjAmt >0&&<ROW label="Capital" value={fmtTotal(tooltipItem.capitalProjAmt)} color={chartCapital} indent muted/>}
{multi&&inclureInterets&&tooltipItem.interetsProjAmt>0&&<ROW label={`Intérêts (${netMode?'nets est.':'bruts'})`} value={fmtTotal(tooltipItem.interetsProjAmt)} color={chartInterets} indent muted/>}
{!multi&&inclureInterets&&<ROW label={`dont intérêts (${netMode?'nets est.':'bruts'})`} value={fmtTotal(tooltipItem.interetsProjAmt)} indent muted/>}
{!multi&&inclureCapital&&tooltipItem.capitalProjAmt>0&&<ROW label="dont capital" value={fmtTotal(tooltipItem.capitalProjAmt)} indent muted/>}
</>}
{hasMix&&<span className="sg-tooltip-value" style={{borderTop:'1px solid var(--border)',paddingTop:4,marginTop:2,display:'flex',justifyContent:'space-between'}}><span>Total</span><span>{fmtTotal(tooltipItem.total)}</span></span>}
{!hasMix&&<span className="sg-tooltip-value" style={{display:'flex',justifyContent:'space-between'}}><span>Total</span><span>{fmtTotal(tooltipItem.total)}</span></span>}
</>);
})()}
</div>
</div>
)}
</div>
{/* ── Légende + sélecteur Reçu/Projeté ── */}
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginTop:8,gap:12,flexWrap:'wrap'}}>
{/* Sélecteur Reçu/Projeté — desktop uniquement (mobile → bottom sheet) */}
{!isMobile && (
<div style={{
display:'inline-flex',
background:'#f0f0f0',
borderRadius:8,
padding:3,
gap:2,
flexShrink:0,
}}>
{[
{key:'actual', label:'Reçu', active:showActual, toggle:toggleActual},
{key:'projected', label:'Projeté', active:showProjected, toggle:toggleProjected},
].map(btn=>(
<button key={btn.key} onClick={()=>btn.toggle()} style={{
border:'none', cursor:'pointer', padding:'5px 14px', borderRadius:6,
fontSize:'var(--fs-sm)',
fontWeight: btn.active ? 600 : 400,
background: btn.active ? '#ffffff' : 'transparent',
color: btn.active ? '#1a1a2e' : '#9ca3af',
boxShadow: btn.active ? '0 1px 3px rgba(0,0,0,0.13)' : 'none',
transition: 'all .15s', lineHeight: 1.4,
}}>{btn.label}</button>
))}
</div>
)}
{/* Légende couleurs */}
<div style={{display:'flex',gap:12,alignItems:'center',flexWrap:'wrap',flex:1,justifyContent:'flex-end'}}>
{activeTypes.map(t=>(
<div key={t.key} style={{display:'flex',gap:8,alignItems:'center'}}>
{showActual&&(
<span style={{display:'flex',gap:4,alignItems:'center',fontSize:'var(--fs-xs)',color:'var(--text-muted)'}}>
<span style={{width:10,height:10,borderRadius:2,background:t.color,opacity:0.82,flexShrink:0}}/>
{t.label}{t.key==='interets'?' reçus':' reçu'}
</span>
)}
{showProjected&&(
<span style={{display:'flex',gap:4,alignItems:'center',fontSize:'var(--fs-xs)',color:'var(--text-muted)'}}>
<span style={{width:10,height:10,borderRadius:2,background:t.color,opacity:0.28,flexShrink:0}}/>
{t.label}{t.key==='interets'?' projetés':' projeté'}
</span>
)}
</div>
))}
</div>
</div>
{/* ── Bottom sheet mobile ── */}
{isMobile && (
<>
{/* Overlay */}
<div
onClick={()=>setSheetOpen(false)}
style={{
position:'fixed', inset:0, zIndex:200,
background:'rgba(0,0,0,0.45)',
opacity: sheetOpen ? 1 : 0,
pointerEvents: sheetOpen ? 'auto' : 'none',
transition:'opacity .25s',
}}
/>
{/* Sheet */}
<div style={{
position:'fixed', bottom:0, left:0, right:0, zIndex:201,
background:'var(--surface)',
borderRadius:'20px 20px 0 0',
borderTop:'1px solid var(--border)',
padding:'12px 20px 32px',
transform: sheetOpen ? 'translateY(0)' : 'translateY(100%)',
transition:'transform .3s cubic-bezier(.32,.72,0,1)',
maxHeight:'85vh', overflowY:'auto',
}}>
{/* Handle */}
<div style={{width:36,height:4,background:'var(--border)',borderRadius:2,margin:'0 auto 20px'}}/>
{/* Types */}
<div style={{fontSize:'var(--fs-xs)',color:'var(--text-muted)',fontWeight:600,textTransform:'uppercase',letterSpacing:'0.06em',marginBottom:12}}>
Types
</div>
{[
{key:'interets', label:netMode?'Intérêts nets':'Intérêts bruts', color:chartInterets, active:inclureInterets, set:setInclureInterets},
{key:'capital', label:'Capital', color:chartCapital, active:inclureCapital, set:setInclureCapital},
{key:'cashback', label:'Cashback', color:chartCashback, active:inclureCashback, set:setInclureCashback},
].map((t,i,arr)=>(
<div key={t.key} onClick={()=>t.set(v=>!v)} style={{
display:'flex', alignItems:'center', justifyContent:'space-between',
padding:'12px 0',
borderBottom: i<arr.length-1 ? '1px solid var(--border)' : 'none',
cursor:'pointer',
}}>
<div style={{display:'flex',alignItems:'center',gap:10}}>
<span style={{width:10,height:10,borderRadius:2,background:t.color,flexShrink:0}}/>
<span style={{fontSize:'var(--fs-sm)',color:'var(--text)',fontWeight:500}}>{t.label}</span>
</div>
{/* Toggle */}
<div style={{
width:44, height:24, borderRadius:12, flexShrink:0,
background: t.active ? t.color : 'var(--border)',
position:'relative', transition:'background .2s',
}}>
<div style={{
width:20, height:20, borderRadius:10, background:'#fff',
position:'absolute', top:2,
left: t.active ? 22 : 2,
transition:'left .2s',
boxShadow:'0 1px 3px rgba(0,0,0,0.2)',
}}/>
</div>
</div>
))}
{/* Période */}
<div style={{fontSize:'var(--fs-xs)',color:'var(--text-muted)',fontWeight:600,textTransform:'uppercase',letterSpacing:'0.06em',margin:'20px 0 12px'}}>
Période
</div>
<div style={{display:'flex',gap:8,flexWrap:'wrap',alignItems:'center'}}>
{availableYears.map(y=>(
<button key={y}
onClick={()=>{ setAnnee(y); if(modeGlobal) toggleModeGlobal(); }}
style={{
padding:'7px 16px', borderRadius:8, fontSize:'var(--fs-sm)',
fontWeight: !modeGlobal && annee===y ? 700 : 500,
background: !modeGlobal && annee===y ? 'var(--primary)' : 'var(--surface-2)',
color: !modeGlobal && annee===y ? '#fff' : 'var(--text)',
border: !modeGlobal && annee===y ? '1px solid var(--primary)' : '1px solid var(--border)',
cursor:'pointer',
}}>
{y}
</button>
))}
<button
onClick={()=>{ if(!modeGlobal) toggleModeGlobal(); }}
style={{
padding:'7px 16px', borderRadius:8, fontSize:'var(--fs-sm)',
fontWeight: modeGlobal ? 700 : 500,
background: modeGlobal ? 'var(--primary)' : 'var(--surface-2)',
color: modeGlobal ? '#fff' : 'var(--text)',
border: modeGlobal ? '1px solid var(--primary)' : '1px solid var(--border)',
cursor:'pointer',
}}>
Tout
</button>
</div>
{/* Affichage */}
<div style={{fontSize:'var(--fs-xs)',color:'var(--text-muted)',fontWeight:600,textTransform:'uppercase',letterSpacing:'0.06em',margin:'20px 0 12px'}}>
Affichage
</div>
{[
{key:'actual', label:'Reçu', active:showActual, toggle:toggleActual},
{key:'projected', label:'Projeté', active:showProjected, toggle:toggleProjected},
].map((btn,i,arr)=>(
<div key={btn.key} onClick={()=>btn.toggle()} style={{
display:'flex', alignItems:'center', justifyContent:'space-between',
padding:'12px 0',
borderBottom: i<arr.length-1 ? '1px solid var(--border)' : 'none',
cursor:'pointer',
}}>
<span style={{fontSize:'var(--fs-sm)',color:'var(--text)',fontWeight:500}}>{btn.label}</span>
<div style={{
width:44, height:24, borderRadius:12, flexShrink:0,
background: btn.active ? 'var(--primary)' : 'var(--border)',
position:'relative', transition:'background .2s',
}}>
<div style={{
width:20, height:20, borderRadius:10, background:'#fff',
position:'absolute', top:2,
left: btn.active ? 22 : 2,
transition:'left .2s',
boxShadow:'0 1px 3px rgba(0,0,0,0.2)',
}}/>
</div>
</div>
))}
</div>
</>
)}
</div>
);
}
+312
View File
@@ -0,0 +1,312 @@
import { useMemo, useState, useRef } from 'react';
/* ── Constantes ─────────────────────────────────────────────── */
const BLUE = '#4fa8e8';
const BG = '#070c15';
const GRID = 'rgba(255,255,255,0.055)';
const LABEL = '#4a5568';
const RANGES = ['1J', '7J', '1M', '3M', 'YTD', '1A', 'TOUT'];
const MOIS_COURT = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.'];
const W = 900, H = 260;
const PAD = { top: 16, right: 16, bottom: 32, left: 70 };
const plotW = W - PAD.left - PAD.right;
const plotH = H - PAD.top - PAD.bottom;
/* ── Helpers ────────────────────────────────────────────────── */
function fmtK(v) {
if (v === 0) return '0 €';
const abs = Math.abs(v);
if (abs >= 1000) return (v / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' k €';
return v.toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' €';
}
function fmtAxisDate(dateStr, range) {
const d = new Date(dateStr + 'T00:00:00');
const day = String(d.getDate()).padStart(2, '0');
const mon = MOIS_COURT[d.getMonth()];
const yr = d.getFullYear();
if (range === '1J' || range === '7J') return `${day}/${String(d.getMonth()+1).padStart(2,'0')}`;
if (range === '1M' || range === '3M') return `${day} ${mon}`;
return `${day}/${String(d.getMonth()+1).padStart(2,'0')}/${String(yr).slice(2)}`;
}
function fmtValueDisplay(v) {
return v.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }) + ' €';
}
/* ── Composant ──────────────────────────────────────────────── */
export default function InvChart({ rows, remboursements, reinvestissements, platYear }) {
const [range, setRange] = useState('TOUT');
const [hover, setHover] = useState(null);
const svgRef = useRef(null);
const wrapRef = useRef(null);
/* ── Date de coupure : 31/12/{platYear} pour les années passées, aujourd'hui pour l'année en cours ou sans filtre ── */
const todayStr = new Date().toISOString().slice(0, 10);
const currentYear = String(new Date().getFullYear());
const cutoffStr = platYear && platYear !== currentYear ? `${platYear}-12-31` : todayStr;
/* ── 1. Courbe capital en cours = investissements capital remboursé ── */
const allPoints = useMemo(() => {
if (!rows?.length) return [];
// Tous les investissements du scope (le capRestant sera 0 pour les remboursés)
const rowIds = new Set(rows.map(r => r.id));
// Variations de capital par date
const deltas = {};
// +montant_investi à chaque date de souscription
for (const r of rows) {
const d = r.date_souscription?.slice(0, 10);
if (!d || d > cutoffStr) continue;
deltas[d] = (deltas[d] || 0) + (r.montant_investi ?? 0);
}
// +réinvestissements à leur date propre (dans le scope, avant coupure)
if (reinvestissements?.length) {
for (const rv of reinvestissements) {
if (!rowIds.has(rv.investissement_id)) continue;
const d = rv.date_reinvestissement?.slice(0, 10);
if (!d || !rv.montant || d > cutoffStr) continue;
deltas[d] = (deltas[d] || 0) + rv.montant;
}
}
// -capital à chaque date de remboursement (investissements du scope, avant coupure)
if (remboursements?.length) {
for (const rb of remboursements) {
if (!rowIds.has(rb.investissement_id)) continue;
const d = rb.date_remb?.slice(0, 10);
if (!d || !rb.capital || d > cutoffStr) continue;
deltas[d] = (deltas[d] || 0) - rb.capital;
}
}
// Tri chronologique + cumul
let cum = 0;
return Object.keys(deltas).sort().map(date => {
cum += deltas[date];
return { date, value: Math.max(0, cum) };
});
}, [rows, remboursements, reinvestissements, cutoffStr]);
const currentValue = allPoints.length ? allPoints[allPoints.length - 1].value : 0;
/* ── 2. Filtrage par plage ── */
const points = useMemo(() => {
if (!allPoints.length) return [];
if (range === 'TOUT') {
const pts = [...allPoints];
if (pts[pts.length - 1].date < cutoffStr) pts.push({ date: cutoffStr, value: pts[pts.length - 1].value });
return pts;
}
const now = new Date(cutoffStr + 'T00:00:00');
let fromDate = new Date(now);
switch (range) {
case '1J': fromDate.setDate(now.getDate() - 1); break;
case '7J': fromDate.setDate(now.getDate() - 7); break;
case '1M': fromDate.setMonth(now.getMonth() - 1); break;
case '3M': fromDate.setMonth(now.getMonth() - 3); break;
case 'YTD': fromDate = new Date(now.getFullYear(), 0, 1); break;
case '1A': fromDate.setFullYear(now.getFullYear() - 1); break;
}
const fromStr = fromDate.toISOString().slice(0, 10);
const before = allPoints.filter(p => p.date < fromStr);
const startV = before.length ? before[before.length - 1].value : 0;
const after = allPoints.filter(p => p.date >= fromStr);
const pts = [{ date: fromStr, value: startV }, ...after];
if (pts[pts.length - 1].date < cutoffStr) pts.push({ date: cutoffStr, value: pts[pts.length - 1].value });
return pts;
}, [allPoints, range, cutoffStr]);
/* ── 3. Échelles ── */
const { xScale, yScale, yTicks, xTicks, minDate, maxDate, yZero } = useMemo(() => {
if (points.length < 2) return {};
const vals = points.map(p => p.value);
const dataMin = Math.min(...vals);
const dataMax = Math.max(...vals);
const lo = Math.min(0, dataMin);
const hi = Math.max(0, dataMax);
const pad = (hi - lo) * 0.1 || 10;
const scaleLo = lo - (lo < 0 ? pad : 0);
const scaleHi = hi + pad;
const valRange = scaleHi - scaleLo || 1;
const ts = points.map(p => new Date(p.date + 'T00:00:00').getTime());
const minDt = ts[0];
const maxDt = ts[ts.length - 1];
const dtRange = maxDt - minDt || 1;
const xScale = d => PAD.left + ((new Date(d + 'T00:00:00').getTime() - minDt) / dtRange) * plotW;
const yScale = v => PAD.top + plotH - ((v - scaleLo) / valRange) * plotH;
const step = (scaleHi - scaleLo) / 4;
const yTicks = Array.from({ length: 5 }, (_, i) => ({ v: scaleLo + step * i, y: yScale(scaleLo + step * i) }));
const nX = Math.min(8, points.length);
const xTicks = Array.from({ length: nX }, (_, i) => {
const idx = Math.round((i / (nX - 1)) * (points.length - 1));
return points[idx];
});
return { xScale, yScale, yTicks, xTicks, minDate: minDt, maxDate: maxDt, yZero: yScale(0) };
}, [points]);
/* ── 4. Chemins SVG ── */
const { linePath, areaPath } = useMemo(() => {
if (!xScale || points.length < 2) return { linePath: '', areaPath: '' };
let line = `M ${xScale(points[0].date).toFixed(1)},${yScale(points[0].value).toFixed(1)}`;
for (let i = 1; i < points.length; i++) {
line += ` H ${xScale(points[i].date).toFixed(1)} V ${yScale(points[i].value).toFixed(1)}`;
}
const zeroY = yZero?.toFixed(1) ?? (PAD.top + plotH).toFixed(1);
const area = `${line} V ${zeroY} H ${PAD.left} Z`;
return { linePath: line, areaPath: area };
}, [points, xScale, yScale, yZero]);
/* ── 5. Hover ── */
const handleMouseMove = (e) => {
if (!svgRef.current || !xScale || points.length < 2) return;
const rect = svgRef.current.getBoundingClientRect();
const svgX = ((e.clientX - rect.left) / rect.width) * W;
const t = minDate + ((svgX - PAD.left) / plotW) * (maxDate - minDate);
let nearest = points[0], minDiff = Infinity;
for (const p of points) {
const diff = Math.abs(new Date(p.date + 'T00:00:00').getTime() - t);
if (diff < minDiff) { minDiff = diff; nearest = p; }
}
setHover({ x: xScale(nearest.date), y: yScale(nearest.value), value: nearest.value, date: nearest.date });
};
/* ── Tooltip ── */
const tooltipStyle = useMemo(() => {
if (!hover) return null;
const xPct = (hover.x / W) * 100;
const yPct = (hover.y / H) * 100;
const anchorRight = xPct > 65;
return {
position: 'absolute',
top: `calc(${yPct}% - 64px)`,
left: anchorRight ? 'auto' : `calc(${xPct}% + 12px)`,
right: anchorRight ? `calc(${100 - xPct}% + 12px)` : 'auto',
transform: 'none',
pointerEvents: 'none',
};
}, [hover]);
if (!allPoints.length) return null;
const displayLabel = platYear ? `31/12/${platYear}` : "Aujourd'hui";
const displayDate = hover
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })
: displayLabel;
const displayValue = fmtValueDisplay(hover ? hover.value : currentValue);
const tooltipDate = hover
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
: null;
return (
<div className="solde-chart-wrap" ref={wrapRef} style={{ position: 'relative' }}>
{/* ── En-tête ── */}
<div className="solde-chart-header">
<div className="solde-chart-info">
<div className="solde-chart-date">Capital investi · {displayDate}</div>
<div className="solde-chart-value">{displayValue}</div>
</div>
<div className="solde-chart-controls">
<div className="solde-chart-ranges">
{RANGES.map(r => (
<button key={r}
className={`solde-range-btn${range === r ? ' active' : ''}`}
onClick={() => { setRange(r); setHover(null); }}>
{r}
</button>
))}
</div>
</div>
</div>
{/* ── SVG ── */}
{xScale && (
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
style={{ width: '100%', height: 'auto', display: 'block', cursor: 'crosshair' }}
onMouseMove={handleMouseMove}
onMouseLeave={() => setHover(null)}
>
<defs>
<linearGradient id="inv-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={BLUE} stopOpacity="0.22" />
<stop offset="70%" stopColor={BLUE} stopOpacity="0.06" />
<stop offset="100%" stopColor={BLUE} stopOpacity="0" />
</linearGradient>
<filter id="inv-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
{/* Grille horizontale */}
{yTicks.map(({ v, y }) => (
<g key={v}>
<line x1={PAD.left} y1={y} x2={W - PAD.right} y2={y}
stroke={GRID} strokeWidth="1" strokeDasharray="3 5" />
<text x={PAD.left - 10} y={y + 4} textAnchor="end"
fill={LABEL} fontSize="11" fontFamily="system-ui,sans-serif">
{fmtK(v)}
</text>
</g>
))}
{/* Ligne zéro */}
{yZero && yZero > PAD.top && yZero < PAD.top + plotH && (
<line x1={PAD.left} y1={yZero} x2={W - PAD.right} y2={yZero}
stroke="rgba(255,255,255,0.18)" strokeWidth="1" />
)}
{/* Aire dégradée + courbe */}
<path d={areaPath} fill="url(#inv-fill)" />
<path d={linePath} fill="none" stroke={BLUE} strokeWidth="1.5"
filter="url(#inv-glow)" strokeLinejoin="round" />
{/* Labels axe X */}
{xTicks.map((p, i) => {
const anchor = i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle';
return (
<text key={i} x={xScale(p.date)} y={H - 8}
textAnchor={anchor} fill={LABEL} fontSize="10"
fontFamily="system-ui,sans-serif">
{fmtAxisDate(p.date, range)}
</text>
);
})}
{/* Hover : ligne verticale + point */}
{hover && (
<g>
<line x1={hover.x} y1={PAD.top} x2={hover.x} y2={PAD.top + plotH}
stroke="rgba(255,255,255,0.12)" strokeWidth="1" strokeDasharray="3 4" />
<circle cx={hover.x} cy={hover.y} r="4.5"
fill={BLUE} stroke={BG} strokeWidth="2" />
</g>
)}
</svg>
)}
{/* ── Tooltip flottant ── */}
{hover && tooltipStyle && (
<div style={tooltipStyle}>
<div className="sg-tooltip">
<span className="sg-tooltip-date">{tooltipDate}</span>
<span className="sg-tooltip-value">{fmtValueDisplay(hover.value)}</span>
</div>
</div>
)}
</div>
);
}
+206
View File
@@ -0,0 +1,206 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { fmtEUR, fmtStatut } from '../utils/format.js';
const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
function endOfMonth(Y, M) {
const d = new Date(Y, M, 0);
return `${Y}-${String(M).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
}
function startOfMonth(Y, M) {
return `${Y}-${String(M).padStart(2,'0')}-01`;
}
const STATUT_BG = {
en_cours: 'var(--b-en_cours-bg)',
rembourse: 'var(--b-rembourse-bg)',
en_retard: 'var(--b-en_retard-bg)',
procedure: 'var(--b-procedure-bg)',
cloture: 'var(--surface-2)',
};
const STATUT_FG = {
en_cours: 'var(--b-en_cours-fg)',
rembourse: 'var(--b-rembourse-fg)',
en_retard: 'var(--b-en_retard-fg)',
procedure: 'var(--b-procedure-fg)',
cloture: 'var(--text-muted)',
};
export default function InvMensuelTable({ rows, allRembs, allReinvests, year }) {
const navigate = useNavigate();
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
const displayYear = year ? Number(year) : currentYear;
/* ── Precompute rembs ── */
const reinvestByInv = useMemo(() => {
const map = {};
for (const rv of (allReinvests || [])) {
const id = rv.investissement_id;
if (!id) continue;
if (!map[id]) map[id] = [];
map[id].push({ date: rv.date_reinvestissement?.slice(0,10), montant: rv.montant || 0 });
}
return map;
}, [allReinvests]);
const capRembByInv = useMemo(() => {
const map = {};
for (const rb of (allRembs || [])) {
const id = rb.investissement_id;
if (!id || rb.type !== 'normal') continue;
if (!map[id]) map[id] = [];
map[id].push({ date: rb.date_remb?.slice(0,10), capital: rb.capital || 0 });
}
return map;
}, [allRembs]);
const lastRembDateMap = useMemo(() => {
const map = {};
for (const rb of (allRembs || [])) {
const id = rb.investissement_id;
const d = rb.date_remb?.slice(0,10);
if (!id || !d) continue;
if (!map[id] || d > map[id]) map[id] = d;
}
return map;
}, [allRembs]);
/* ── Capital encours d'un investissement à fin de mois M ── */
const getCapital = (inv, Y, M) => {
const endM = endOfMonth(Y, M);
if (inv.date_souscription > endM) return 0;
const startM = startOfMonth(Y, M);
const ACTIVE = ['en_cours', 'en_retard', 'procedure'];
const isActive = ACTIVE.includes(inv.statut) ||
((inv.date_cible || lastRembDateMap[inv.id] || null) >= startM);
if (!isActive) return 0;
const reinvM = (reinvestByInv[inv.id] || [])
.filter(rv => rv.date && rv.date <= endM)
.reduce((s, rv) => s + rv.montant, 0);
const capRembM = (capRembByInv[inv.id] || [])
.filter(rb => rb.date && rb.date <= endM)
.reduce((s, rb) => s + rb.capital, 0);
return Math.max(0, inv.montant_investi + reinvM - capRembM);
};
/* ── Grille : une ligne par investissement ── */
const grid = useMemo(() => {
if (!rows?.length) return [];
return rows
.map(inv => ({
inv,
months: Array.from({ length: 12 }, (_, i) => getCapital(inv, displayYear, i + 1)),
}))
.filter(r => r.months.some(v => v > 0))
.sort((a, b) =>
(a.inv.date_souscription || '') < (b.inv.date_souscription || '') ? -1 : 1
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows, displayYear, reinvestByInv, capRembByInv, lastRembDateMap]);
const monthTotals = useMemo(() =>
Array.from({ length: 12 }, (_, i) => grid.reduce((s, r) => s + r.months[i], 0)),
[grid]
);
if (!grid.length) {
return (
<div style={{ padding: '24px', color: 'var(--text-muted)', fontSize: 'var(--fs-sm)', textAlign: 'center' }}>
Aucun investissement actif pour {displayYear}.
</div>
);
}
return (
<div style={{ overflowX: 'auto', position: 'relative', zIndex: 0 }}>
<table className="tip-table">
<thead>
<tr>
<th className="tip-th-empty" style={{ minWidth: 200 }} />
<th className="tip-th-empty" style={{ minWidth: 90 }} />
<th className="tip-th-year" colSpan={12}>{displayYear}</th>
</tr>
<tr>
<th className="tip-th-name tip-th-name-amber" style={{ minWidth: '22ch', maxWidth: '40ch' }}>Investissement</th>
<th style={{
padding: '7px 10px', background: 'var(--surface-2)', color: 'var(--text)',
fontWeight: 600, fontSize: 'var(--fs-xs)', textAlign: 'left',
borderRight: '1px solid var(--border)', whiteSpace: 'nowrap',
}}>Statut</th>
{MOIS_LONG.map((m, i) => (
<th key={m}
className={`tip-th-month${displayYear === currentYear && i === currentMonth - 1 ? ' tip-th-month-current' : ''}`}>
{m}
</th>
))}
</tr>
</thead>
<tbody>
{grid.map(({ inv, months }) => (
<tr key={inv.id} className="tip-row-plat"
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/investissements/${inv.id}`)}>
<td className="tip-td-name" style={{ whiteSpace: 'normal', maxWidth: '40ch', wordBreak: 'break-word' }}>
{inv.nom_projet || '—'}
</td>
<td style={{ padding: '8px 10px', whiteSpace: 'nowrap', borderRight: '1px solid var(--border)' }}>
<span style={{
display: 'inline-block', padding: '2px 8px', borderRadius: 4,
fontSize: 'var(--fs-xs)', fontWeight: 600,
background: STATUT_BG[inv.statut] || 'var(--surface-2)',
color: STATUT_FG[inv.statut] || 'var(--text-muted)',
}}>
{fmtStatut(inv.statut)}
</span>
</td>
{months.map((v, mi) => {
const curClass = displayYear === currentYear && mi === currentMonth - 1 ? ' tip-col-current' : '';
if (v === 0) {
// Avant la date de souscription
const subYear = Number(inv.date_souscription?.slice(0, 4));
const subMo = Number(inv.date_souscription?.slice(5, 7)) - 1;
const isBefore = inv.date_souscription && (
subYear > displayYear || (subYear === displayYear && mi < subMo)
);
// Après le dernier remboursement (prêt remboursé)
const lastDate = lastRembDateMap[inv.id];
const isAfter = inv.statut === 'rembourse' && lastDate && (() => {
const lastYear = Number(lastDate.slice(0, 4));
const lastMo = Number(lastDate.slice(5, 7)) - 1;
if (lastYear < displayYear) return true;
if (lastYear === displayYear) return mi > lastMo;
return false;
})();
if (isBefore || isAfter) {
return <td key={mi} className={`tip-td-closed${curClass}`} />;
}
}
return (
<td key={mi} className={`tip-td-num${curClass}`}>
{v > 0 ? fmtEUR(v) : <span className="tip-dash"></span>}
</td>
);
})}
</tr>
))}
</tbody>
<tfoot>
<tr className="tip-footer-total">
<td className="tip-td-name">Total</td>
<td />
{monthTotals.map((v, i) => (
<td key={i}
className={`tip-td-num${displayYear === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`}>
{v > 0 ? fmtEUR(v) : <span className="tip-dash"></span>}
</td>
))}
</tr>
</tfoot>
</table>
</div>
);
}
+195
View File
@@ -0,0 +1,195 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { api } from '../api.js';
/**
* InvSelect — multi-select with checkboxes + inline "Add item"
* Generic replacement for CategorySelect, works with categories_inv / secteurs_inv.
*
* Props:
* items : { id, nom, is_global }[] — liste complète fournie par le parent
* selected : number[] — ids sélectionnés
* onChange : (ids: number[]) => void
* addApiPath : string — ex. '/categories-inv' | '/secteurs-inv'
* onItemAdded : ({ id, nom, is_global }) => void — appelé après création inline
* emptyLabel : string — texte si rien de sélectionné
* addLabel : string — texte du bouton "Ajouter"
* inputPlaceholder : string — placeholder du champ de création
*/
export default function InvSelect({
items = [],
selected = [],
onChange,
addApiPath,
onItemAdded,
emptyLabel = 'Aucun élément sélectionné',
addLabel = 'Ajouter un élément',
inputPlaceholder = 'Nom…',
inheritedIds = [],
}) {
const [open, setOpen] = useState(false);
const [adding, setAdding] = useState(false);
const [newName, setNewName] = useState('');
const [err, setErr] = useState(null);
const [busy, setBusy] = useState(false);
const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 0 });
const wrapRef = useRef(null);
const triggerRef = useRef(null);
useLayoutEffect(() => {
if (!open || !triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
setDropPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
}, [open]);
useEffect(() => {
if (!open) return;
const close = (e) => {
if (wrapRef.current?.contains(e.target)) return;
const drop = document.getElementById('inv-select-dropdown-portal');
if (drop?.contains(e.target)) return;
setOpen(false);
};
const closeOnScroll = (e) => {
const drop = document.getElementById('inv-select-dropdown-portal');
if (drop?.contains(e.target)) return; // scroll dans le dropdown — on garde ouvert
setOpen(false);
};
document.addEventListener('mousedown', close);
window.addEventListener('scroll', closeOnScroll, true);
window.addEventListener('resize', closeOnScroll);
return () => {
document.removeEventListener('mousedown', close);
window.removeEventListener('scroll', closeOnScroll, true);
window.removeEventListener('resize', closeOnScroll);
};
}, [open]);
const toggle = (id) => {
if (inheritedIds.includes(id)) return; // tag hérité du référentiel, non modifiable
onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]);
};
const addItem = async (e) => {
e.preventDefault();
if (!newName.trim()) return;
setBusy(true); setErr(null);
try {
const item = await api.post(addApiPath, { nom: newName.trim() });
onItemAdded?.(item);
onChange([...selected, item.id]);
setNewName('');
setAdding(false);
} catch (e) {
setErr(e.message);
} finally {
setBusy(false);
}
};
const triggerLabel = (() => {
if (selected.length === 0) return emptyLabel;
const names = items.filter(c => selected.includes(c.id)).map(c => c.nom);
if (names.length <= 2) return names.join(', ');
return `${names.length} éléments sélectionnés`;
})();
const dropdown = open ? (
<div
id="inv-select-dropdown-portal"
className="cat-select-dropdown"
role="listbox"
aria-multiselectable="true"
style={{ position: 'fixed', top: dropPos.top, left: dropPos.left, width: dropPos.width, zIndex: 9999 }}
>
{items.length === 0 && (
<div className="cat-select-empty">{emptyLabel}</div>
)}
{items.map(item => {
const checked = selected.includes(item.id);
const inherited = inheritedIds.includes(item.id);
return (
<label key={item.id} className={`cat-select-item${checked ? ' checked' : ''}${inherited ? ' inherited' : ''}`}
title={inherited ? 'Hérité du référentiel — non modifiable' : undefined}>
<input type="checkbox" checked={checked || inherited} disabled={inherited} onChange={() => toggle(item.id)} />
<span>{item.nom}</span>
{inherited
? <span style={{ marginLeft: 'auto', fontSize: 10, fontWeight: 600, padding: '1px 5px',
borderRadius: 3, background: 'var(--accent)', color: '#fff', opacity: .85 }}>Réf</span>
: checked && (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ marginLeft: 'auto', color: 'var(--accent)' }} aria-hidden="true">
<polyline points="20 6 9 17 4 12"/>
</svg>
)
}
</label>
);
})}
{addApiPath && (
<>
<div className="cat-select-sep" />
{!adding ? (
<button type="button" className="cat-select-add-btn"
onClick={() => { setAdding(true); setErr(null); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
aria-hidden="true">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
{addLabel}
</button>
) : (
<form onSubmit={addItem} className="cat-select-new-form">
<input
autoFocus
value={newName}
onChange={e => setNewName(e.target.value)}
placeholder={inputPlaceholder}
maxLength={200}
/>
<div className="cat-select-new-actions">
<button type="submit" className="primary" disabled={busy || !newName.trim()}>
{busy ? '…' : 'Créer'}
</button>
<button type="button" className="ghost"
onClick={() => { setAdding(false); setNewName(''); setErr(null); }}>
Annuler
</button>
</div>
{err && <div className="cat-select-err">{err}</div>}
</form>
)}
</>
)}
</div>
) : null;
return (
<>
<div ref={wrapRef} className="cat-select-wrap">
<button
ref={triggerRef}
type="button"
className={`cat-select-trigger${open ? ' open' : ''}`}
onClick={() => { setOpen(o => !o); setAdding(false); setErr(null); }}
aria-haspopup="listbox"
aria-expanded={open}
>
<span className="cat-select-label">{triggerLabel}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ flexShrink: 0, transition: 'transform .15s', transform: open ? 'rotate(0deg)' : 'rotate(180deg)' }}
aria-hidden="true">
<path d="M18 15l-6-6-6 6"/>
</svg>
</button>
</div>
{dropdown}
</>
);
}
+341
View File
@@ -0,0 +1,341 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useUi } from '../context/UiContext.jsx';
import Logo from './Logo.jsx';
import UserMenu from './UserMenu.jsx';
/* ── Icônes nav ─────────────────────────────────────────────── */
const ICONS_BASE = '/api/icons-files/';
const I = ({ children }) => (
<svg width="16" height="16" viewBox="0 0 18 18" fill="none"
stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"
aria-hidden="true">
{children}
</svg>
);
const IconDashboard = () => <I><rect x="1" y="1" width="7" height="7" rx="1.5"/><rect x="10" y="1" width="7" height="7" rx="1.5"/><rect x="1" y="10" width="7" height="7" rx="1.5"/><rect x="10" y="10" width="7" height="7" rx="1.5"/></I>;
const IconDeposits = () => <I><line x1="6" y1="14" x2="6" y2="4"/><polyline points="3,7 6,4 9,7"/><line x1="12" y1="4" x2="12" y2="14"/><polyline points="9,11 12,14 15,11"/></I>;
const IconInvestments = () => <I><polyline points="1,15 5,9 9,11 15,3"/><polyline points="11,3 15,3 15,7"/></I>;
const IconRepayments = () => <I><path d="M15 9A6 6 0 1 1 9 3"/><polyline points="15,3 15,9 9,9"/></I>;
const IconFlatTax = () => <I><circle cx="9" cy="9" r="7.5"/><path d="M9 1.5a11 11 0 0 1 3.5 7.5 11 11 0 0 1-3.5 7.5 11 11 0 0 1-3.5-7.5 11 11 0 0 1 3.5-7.5z"/><line x1="1.5" y1="9" x2="16.5" y2="9"/></I>;
/* Icône nav hybride : bibliothèque si dispo, sinon fallback SVG inline */
function NavIcon({ libFilename, Fallback }) {
if (libFilename) {
return (
<img
src={`${ICONS_BASE}${libFilename}`}
width="24" height="24"
className="nav-lib-icon"
aria-hidden="true"
alt=""
/>
);
}
return <Fallback />;
}
/* Bouton « réduire » (visible dans la sidebar étendue) */
const IconPanelCollapse = () => (
<svg width="18" height="18" viewBox="0 0 20 18" fill="none"
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
aria-hidden="true">
<rect x=".75" y=".75" width="18.5" height="16.5" rx="2.5"/>
<line x1="7" y1=".75" x2="7" y2="17.25"/>
<path d="M13 5.5 L10 9 L13 12.5"/>
</svg>
);
/* Bouton « étendre » (apparaît au hover du logo en mode réduit) */
const IconPanelExpand = () => (
<svg width="18" height="18" viewBox="0 0 20 18" fill="none"
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
aria-hidden="true">
<rect x=".75" y=".75" width="18.5" height="16.5" rx="2.5"/>
<line x1="7" y1=".75" x2="7" y2="17.25"/>
<path d="M3 5.5 L6 9 L3 12.5"/>
</svg>
);
/* ── Recherche rapide de projet ─────────────────────────────── */
function ProjectSearch() {
const navigate = useNavigate();
const { activeId, activeView } = useInvestisseur();
const [query, setQuery] = useState('');
const [allInv, setAllInv] = useState([]);
const [open, setOpen] = useState(false);
const [activeIdx, setActiveIdx] = useState(-1);
const inputRef = useRef(null);
const wrapRef = useRef(null);
/* Chargement (ou rechargement) des investissements */
const loadInv = useCallback(async () => {
try {
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
const rows = await api.get('/investissements', scopeParams);
setAllInv(rows);
} catch {}
}, [activeView]);
useEffect(() => { loadInv(); }, [loadInv, activeId]);
/* Raccourci clavier global Ctrl+K / Cmd+K */
useEffect(() => {
const h = e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
inputRef.current?.focus();
inputRef.current?.select();
}
};
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, []);
/* Fermeture au clic extérieur */
useEffect(() => {
const h = e => { if (!wrapRef.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, []);
/* Résultats filtrés */
const results = (() => {
const q = query.trim().toLowerCase();
if (!q) return [];
return allInv
.filter(r => r.nom_projet?.toLowerCase().includes(q) || r.plateforme_nom?.toLowerCase().includes(q))
.slice(0, 8);
})();
/* Synchronise l'ouverture du dropdown */
useEffect(() => {
setOpen(results.length > 0 && query.trim().length > 0);
setActiveIdx(-1);
}, [results.length, query]); /* eslint-disable-line */
const goTo = (inv) => {
setQuery(''); setOpen(false);
navigate(`/investissements/${inv.id}`);
};
const handleKeyDown = (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIdx(i => Math.min(i + 1, results.length - 1)); }
if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIdx(i => Math.max(i - 1, -1)); }
if (e.key === 'Enter') { e.preventDefault(); if (activeIdx >= 0) goTo(results[activeIdx]); else if (results.length === 1) goTo(results[0]); }
if (e.key === 'Escape') { setOpen(false); setQuery(''); inputRef.current?.blur(); }
};
const STATUT_LABELS = {
en_cours: 'en cours',
rembourse: 'remboursé',
en_retard: 'en retard',
procedure: 'procédure',
cloture: 'clôturé',
};
const statutColor = (s) => {
if (s === 'en_cours') return 'var(--b-en_cours-fg)';
if (s === 'rembourse') return 'var(--b-rembourse-fg)';
if (s === 'en_retard') return 'var(--b-en_retard-fg)';
if (s === 'procedure') return 'var(--b-procedure-fg)';
return 'var(--text-muted)';
};
return (
<div ref={wrapRef} style={{ position: 'relative' }}>
<div className="project-search-wrap">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
style={{ opacity: 0.7, flexShrink: 0 }}>
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
ref={inputRef}
className="project-search-input"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Rechercher un projet…"
autoComplete="off"
spellCheck="false"
/>
{query ? (
<button className="project-search-clear"
onMouseDown={e => { e.preventDefault(); setQuery(''); setOpen(false); inputRef.current?.focus(); }}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.8" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
) : (
<kbd className="project-search-kbd">K</kbd>
)}
</div>
{open && (
<div className="project-search-dropdown">
{results.map((inv, i) => (
<div key={inv.id}
className={`project-search-item${activeIdx === i ? ' active' : ''}`}
onMouseDown={() => goTo(inv)}
onMouseEnter={() => setActiveIdx(i)}
>
<div className="project-search-item-name">{inv.nom_projet}</div>
<div className="project-search-item-meta">
<span>{inv.plateforme_nom}</span>
<span>·</span>
<span style={{ color: statutColor(inv.statut) }}>{STATUT_LABELS[inv.statut] ?? inv.statut?.replace('_', ' ')}</span>
{inv.montant_investi != null && (
<>
<span>·</span>
<span>{inv.montant_investi.toLocaleString('fr-FR')} </span>
</>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
/* ── Layout ─────────────────────────────────────────────────── */
function IconPlusCircle() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="16"/>
<line x1="8" y1="12" x2="16" y2="12"/>
</svg>
);
}
export default function Layout() {
const { sidebarCollapsed, toggleSidebar, displayMode, setDisplayMode } = useUi();
const navigate = useNavigate();
const [navIcons, setNavIcons] = useState({});
useEffect(() => {
api.get('/icons').then(rows => {
const m = {};
rows.forEach(r => { m[r.name] = r.filename; });
setNavIcons(m);
}).catch(() => {});
}, []);
return (
<div className={`app-shell${sidebarCollapsed ? ' sidebar-collapsed' : ''}`}>
<aside className="sidebar">
{/* ── Header ─────────────────────────────────────────── */}
<div className="sidebar-brand">
<div className="sidebar-brand-logo">
<Logo size={34} />
</div>
<span className="sidebar-brand-text">Crowdlending</span>
{/* Réduire (visible seulement en mode étendu) */}
<button
className="sidebar-panel-btn"
onClick={toggleSidebar}
title="Réduire le menu"
aria-label="Réduire le menu"
>
<IconPanelCollapse />
</button>
{/* Étendre — overlay au hover du logo en mode réduit */}
<button
className="sidebar-expand-overlay"
onClick={toggleSidebar}
title="Ouvrir le menu"
aria-label="Ouvrir le menu"
>
<IconPanelExpand />
</button>
</div>
{/* ── Navigation ─────────────────────────────────────── */}
<nav className="sidebar-nav">
<NavLink to="/" end title="Tableau de bord">
<NavIcon libFilename={navIcons.dashboard} Fallback={IconDashboard} /><span className="nav-label">Tableau de bord</span>
</NavLink>
<NavLink to="/plateformes" title="Plateformes">
<NavIcon libFilename={navIcons.plateforme} Fallback={IconInvestments} /><span className="nav-label">Plateformes</span>
</NavLink>
<NavLink to="/investissements" title="Investissements">
<NavIcon libFilename={navIcons.investissement} Fallback={IconInvestments} /><span className="nav-label">Investissements</span>
</NavLink>
<NavLink to="/depots-retraits" title="Dépôts / Retraits">
<NavIcon libFilename={navIcons['depots-retraits']} Fallback={IconDeposits} /><span className="nav-label">Dépôts / Retraits</span>
</NavLink>
<NavLink to="/remboursements" title="Remboursements">
<NavIcon libFilename={navIcons.remboursement} Fallback={IconRepayments} /><span className="nav-label">Remboursements</span>
</NavLink>
<NavLink to="/taxreport" title="Fiscalité">
<NavIcon libFilename={navIcons.tax} Fallback={IconFlatTax} /><span className="nav-label">Fiscalité</span>
</NavLink>
</nav>
{/* ── Pied : menu utilisateur ─────────────────────────── */}
<UserMenu />
</aside>
<main className="main">
<div className="topbar topbar-global">
<ProjectSearch />
<div className="topbar-right">
<button
className="btn-add-invest"
onClick={() => navigate('/investissements?new=1')}
>
<IconPlusCircle />
Ajout Investissement
</button>
<button
className="btn-add-invest"
onClick={() => navigate('/remboursements?new=1')}
>
<IconPlusCircle />
Nouveau remboursement
</button>
<button
className="btn-add-invest"
onClick={() => navigate('/depots-retraits?new=1')}
>
<IconPlusCircle />
Nouveau dépôt/retrait
</button>
<div className="display-toggle" role="group" aria-label="Mode d'affichage">
<button
className={`display-toggle-btn${displayMode === 'brut' ? ' active' : ''}`}
onClick={() => setDisplayMode('brut')}
aria-pressed={displayMode === 'brut'}
>
Brut
</button>
<button
className={`display-toggle-btn${displayMode === 'net' ? ' active' : ''}`}
onClick={() => setDisplayMode('net')}
aria-pressed={displayMode === 'net'}
>
Net
</button>
</div>
</div>
</div>
<Outlet />
</main>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
export default function Logo({ size = 32 }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
width={size}
height={size}
style={{ display: 'block', flexShrink: 0 }}
aria-hidden="true"
>
<rect width="48" height="48" rx="10" fill="#1e3a8a" />
<rect x="7" y="31" width="9" height="11" rx="2" fill="#93c5fd" />
<rect x="20" y="21" width="9" height="21" rx="2" fill="#60a5fa" />
<rect x="33" y="11" width="9" height="31" rx="2" fill="white" />
<polygon points="37.5,4 44,12 31,12" fill="#4ade80" />
</svg>
);
}
+19
View File
@@ -0,0 +1,19 @@
export default function Modal({ open, title, onClose, children, footer, width = 600 }) {
if (!open) return null;
return (
<div className="modal-backdrop">
<div
className="card"
style={{ width: '100%', maxWidth: width, maxHeight: '90vh', overflow: 'auto' }}
onClick={(e) => e.stopPropagation()}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h3 style={{ margin: 0 }}>{title}</h3>
<button className="ghost" onClick={onClose}></button>
</div>
{children}
{footer && <div style={{ marginTop: 16, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>{footer}</div>}
</div>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react';
import { api } from '../api.js';
const ICONS_BASE = '/api/icons-files/';
let _cache = null;
let _promise = null;
function getIcons() {
if (_cache) return Promise.resolve(_cache);
if (!_promise) {
_promise = api.get('/icons')
.then(rows => { _cache = {}; rows.forEach(r => { _cache[r.name] = r.filename; }); return _cache; })
.catch(() => { _cache = {}; return _cache; });
}
return _promise;
}
export default function PageIcon({ name, size = 40 }) {
const [filename, setFilename] = useState(() => _cache?.[name] ?? null);
useEffect(() => {
if (_cache) { setFilename(_cache[name] ?? null); return; }
getIcons().then(m => setFilename(m[name] ?? null));
}, [name]);
if (!filename) return null;
return (
<img
src={`${ICONS_BASE}${filename}`}
width={size}
height={size}
alt=""
aria-hidden="true"
style={{
display: 'inline',
verticalAlign: 'middle',
marginRight: 10,
objectFit: 'contain',
opacity: 0.85,
}}
/>
);
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Barre de pagination réutilisable.
* Props : page, setPage, pageSize, setPageSize, totalPages, totalItems, PAGE_SIZES
*/
export default function Pagination({ page, setPage, pageSize, setPageSize, totalPages, totalItems, PAGE_SIZES }) {
if (totalItems === 0) return null;
const start = (page - 1) * pageSize + 1;
const end = Math.min(page * pageSize, totalItems);
return (
<div className="pagination-bar">
<span className="pagination-info">
{start}{end} sur {totalItems}
</span>
<div className="pagination-controls">
<label className="pagination-size-label">
Lignes&nbsp;:&nbsp;
<select
className="pagination-size-select"
value={pageSize}
onChange={e => setPageSize(Number(e.target.value))}
>
{PAGE_SIZES.map(n => (
<option key={n} value={n}>{n}</option>
))}
</select>
</label>
<button
className="pagination-btn"
onClick={() => setPage(1)}
disabled={page === 1}
title="Première page"
>«</button>
<button
className="pagination-btn"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
title="Page précédente"
></button>
<span className="pagination-pages">{page} / {totalPages}</span>
<button
className="pagination-btn"
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
title="Page suivante"
></button>
<button
className="pagination-btn"
onClick={() => setPage(totalPages)}
disabled={page === totalPages}
title="Dernière page"
>»</button>
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { useEffect } from 'react';
/**
* ResultBanner — bannière succès/erreur avec auto-dismiss et × à droite.
*
* Props:
* result : { ok: bool, msg: string } | null
* onDismiss : () => void — appelé à la fermeture (manuelle ou auto)
* delay : number — délai auto-dismiss en ms (défaut 4000)
*/
export default function ResultBanner({ result, onDismiss, delay = 4000, style = {} }) {
useEffect(() => {
if (!result) return;
const t = setTimeout(onDismiss, delay);
return () => clearTimeout(t);
}, [result, delay, onDismiss]);
if (!result) return null;
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 14px',
borderRadius: 8,
fontSize: 13,
background: result.ok ? 'rgba(34,197,94,.1)' : 'rgba(239,68,68,.1)',
color: result.ok ? '#16a34a' : '#dc2626',
border: `1px solid ${result.ok ? 'rgba(34,197,94,.3)' : 'rgba(239,68,68,.3)'}`,
...style,
}}>
<span>{result.msg}</span>
<button
onClick={onDismiss}
style={{
marginLeft: 16,
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'inherit',
fontSize: 18,
lineHeight: 1,
padding: '0 2px',
flexShrink: 0,
}}
aria-label="Fermer"
>×</button>
</div>
);
}
+297
View File
@@ -0,0 +1,297 @@
import { useMemo, useState, useRef, useEffect } from 'react';
/* ── Constantes ─────────────────────────────────────────────── */
const GOLD = '#4fa8e8'; // bleu ciel — accord avec le thème navy du site
const BG = '#070c15';
const GRID = 'rgba(255,255,255,0.055)';
const LABEL = '#4a5568';
const RANGES = ['1J', '7J', '1M', '3M', 'YTD', '1A', 'TOUT'];
const MOIS_COURT = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.'];
/* ── Helpers ────────────────────────────────────────────────── */
function fmtK(v) {
if (v === 0) return '0 €';
const abs = Math.abs(v);
if (abs >= 1000) return (v / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' k €';
return v.toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' €';
}
function fmtAxisDate(dateStr, range) {
const d = new Date(dateStr + 'T00:00:00');
const day = String(d.getDate()).padStart(2, '0');
const mon = MOIS_COURT[d.getMonth()];
const yr = d.getFullYear();
if (range === '1J' || range === '7J') return `${day}/${String(d.getMonth()+1).padStart(2,'0')}`;
if (range === '1M') return `${day} ${mon}`;
if (range === '3M') return `${day} ${mon}`;
return `${day}/${String(d.getMonth()+1).padStart(2,'0')}/${String(yr).slice(2)}`;
}
function fmtValueDisplay(v) {
return v.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }) + ' €';
}
function fmtTodayFull() {
return new Date().toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' });
}
/* ── Composant ──────────────────────────────────────────────── */
export default function SoldeChart({ rows }) {
const [range, setRange] = useState('TOUT');
const [hover, setHover] = useState(null); // { x, y, value, date }
const svgRef = useRef(null);
const wrapRef = useRef(null);
/* ── 1. Cumul complet (toutes les données) ── */
const allPoints = useMemo(() => {
if (!rows?.length) return [];
const byDate = {};
for (const r of rows) {
const d = r.date_operation.slice(0, 10);
byDate[d] = (byDate[d] || 0) + (r.type === 'depot' ? r.montant : -r.montant);
}
let cum = 0;
return Object.keys(byDate).sort().map(date => {
cum += byDate[date];
return { date, value: cum };
});
}, [rows]);
const todayStr = new Date().toISOString().slice(0, 10);
const currentValue = allPoints.length ? allPoints[allPoints.length - 1].value : 0;
/* ── 2. Filtrage par plage ── */
const points = useMemo(() => {
if (!allPoints.length) return [];
if (range === 'TOUT') {
const pts = [...allPoints];
if (pts[pts.length - 1].date < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value });
return pts;
}
const now = new Date();
let fromDate = new Date(now);
switch (range) {
case '1J': fromDate.setDate(now.getDate() - 1); break;
case '7J': fromDate.setDate(now.getDate() - 7); break;
case '1M': fromDate.setMonth(now.getMonth() - 1); break;
case '3M': fromDate.setMonth(now.getMonth() - 3); break;
case 'YTD': fromDate = new Date(now.getFullYear(), 0, 1); break;
case '1A': fromDate.setFullYear(now.getFullYear() - 1); break;
}
const fromStr = fromDate.toISOString().slice(0, 10);
const before = allPoints.filter(p => p.date < fromStr);
const startV = before.length ? before[before.length - 1].value : 0;
const after = allPoints.filter(p => p.date >= fromStr);
const pts = [{ date: fromStr, value: startV }, ...after];
if (pts[pts.length - 1].date < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value });
return pts;
}, [allPoints, range, todayStr]);
/* ── 3. SVG dimensions ── */
const W = 900, H = 260;
const PAD = { top: 16, right: 16, bottom: 32, left: 70 };
const plotW = W - PAD.left - PAD.right;
const plotH = H - PAD.top - PAD.bottom;
/* ── 4. Échelles ── */
const { xScale, yScale, yTicks, xTicks, minDate, maxDate, yZero } = useMemo(() => {
if (points.length < 2) return {};
const vals = points.map(p => p.value);
const dataMin = Math.min(...vals);
const dataMax = Math.max(...vals);
// Inclure 0 pour ancrer l'axe ; ajouter 10 % de padding
const lo = Math.min(0, dataMin);
const hi = Math.max(0, dataMax);
const pad = (hi - lo) * 0.1 || 10;
const scaleLo = lo - (lo < 0 ? pad : 0);
const scaleHi = hi + (hi > 0 ? pad : pad);
const valRange = scaleHi - scaleLo || 1;
const ts = points.map(p => new Date(p.date + 'T00:00:00').getTime());
const minDt = ts[0];
const maxDt = ts[ts.length - 1];
const dtRange = maxDt - minDt || 1;
const xScale = d => PAD.left + ((new Date(d + 'T00:00:00').getTime() - minDt) / dtRange) * plotW;
const yScale = v => PAD.top + plotH - ((v - scaleLo) / valRange) * plotH;
// Y ticks : 5 niveaux couvrant la plage réelle
const step = (scaleHi - scaleLo) / 4;
const yTicks = Array.from({ length: 5 }, (_, i) => ({ v: scaleLo + step * i, y: yScale(scaleLo + step * i) }));
// X ticks : max 8
const nX = Math.min(8, points.length);
const xTicks = Array.from({ length: nX }, (_, i) => {
const idx = Math.round((i / (nX - 1)) * (points.length - 1));
return points[idx];
});
return { xScale, yScale, yTicks, xTicks, minDate: minDt, maxDate: maxDt, yZero: yScale(0) };
}, [points, plotW, plotH, PAD]);
/* ── 5. Chemins SVG ── */
const { linePath, areaPath } = useMemo(() => {
if (!xScale || points.length < 2) return { linePath: '', areaPath: '' };
let line = `M ${xScale(points[0].date).toFixed(1)},${yScale(points[0].value).toFixed(1)}`;
for (let i = 1; i < points.length; i++) {
line += ` H ${xScale(points[i].date).toFixed(1)} V ${yScale(points[i].value).toFixed(1)}`;
}
// Refermer l'aire sur la ligne zéro (et non le bas du chart)
const zeroY = yZero?.toFixed(1) ?? (PAD.top + plotH).toFixed(1);
const area = `${line} V ${zeroY} H ${PAD.left} Z`;
return { linePath: line, areaPath: area };
}, [points, xScale, yScale, yZero, PAD, plotH]);
/* ── 6. Hover ── */
const handleMouseMove = (e) => {
if (!svgRef.current || !xScale || points.length < 2) return;
const rect = svgRef.current.getBoundingClientRect();
const svgX = ((e.clientX - rect.left) / rect.width) * W;
const t = minDate + ((svgX - PAD.left) / plotW) * (maxDate - minDate);
// Find nearest point
let nearest = points[0], minDiff = Infinity;
for (const p of points) {
const diff = Math.abs(new Date(p.date + 'T00:00:00').getTime() - t);
if (diff < minDiff) { minDiff = diff; nearest = p; }
}
setHover({
x: xScale(nearest.date),
y: yScale(nearest.value),
value: nearest.value,
date: nearest.date,
});
};
/* ── Tooltip flottant : DOIT être avant tout return conditionnel (Rules of Hooks) ── */
const tooltipStyle = useMemo(() => {
if (!hover) return null;
const xPct = (hover.x / W) * 100;
const yPct = (hover.y / H) * 100;
const anchorRight = xPct > 65;
return {
position: 'absolute',
top: `calc(${yPct}% - 64px)`,
left: anchorRight ? 'auto' : `calc(${xPct}% + 12px)`,
right: anchorRight ? `calc(${100 - xPct}% + 12px)` : 'auto',
transform: 'none',
pointerEvents: 'none',
};
}, [hover]);
if (!allPoints.length) return null;
/* ── Date affichée dans l'en-tête (hover ou aujourd'hui) ── */
const displayDate = hover
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })
: fmtTodayFull();
const displayValue = hover ? fmtValueDisplay(hover.value) : fmtValueDisplay(currentValue);
const tooltipDate = hover
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
: null;
return (
<div className="solde-chart-wrap" ref={wrapRef} style={{ position: 'relative' }}>
{/* ── En-tête ── */}
<div className="solde-chart-header">
<div className="solde-chart-info">
<div className="solde-chart-date">{displayDate}</div>
<div className="solde-chart-value">{displayValue}</div>
</div>
<div className="solde-chart-controls">
<div className="solde-chart-ranges">
{RANGES.map(r => (
<button key={r}
className={`solde-range-btn${range === r ? ' active' : ''}`}
onClick={() => { setRange(r); setHover(null); }}>
{r}
</button>
))}
</div>
</div>
</div>
{/* ── SVG ── */}
{xScale && (
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
style={{ width: '100%', height: 'auto', display: 'block', cursor: 'crosshair' }}
onMouseMove={handleMouseMove}
onMouseLeave={() => setHover(null)}
>
<defs>
<linearGradient id="sg-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={GOLD} stopOpacity="0.22" />
<stop offset="70%" stopColor={GOLD} stopOpacity="0.06" />
<stop offset="100%" stopColor={GOLD} stopOpacity="0" />
</linearGradient>
<filter id="sg-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
{/* Grille horizontale */}
{yTicks.map(({ v, y }) => (
<g key={v}>
<line x1={PAD.left} y1={y} x2={W - PAD.right} y2={y}
stroke={GRID} strokeWidth="1" strokeDasharray="3 5" />
<text x={PAD.left - 10} y={y + 4} textAnchor="end"
fill={LABEL} fontSize="11" fontFamily="system-ui,sans-serif">
{fmtK(v)}
</text>
</g>
))}
{/* Ligne zéro */}
{yZero && yZero > PAD.top && yZero < PAD.top + plotH && (
<line x1={PAD.left} y1={yZero} x2={W - PAD.right} y2={yZero}
stroke="rgba(255,255,255,0.18)" strokeWidth="1" />
)}
{/* Remplissage dégradé */}
<path d={areaPath} fill="url(#sg-fill)" />
{/* Ligne principale */}
<path d={linePath} fill="none" stroke={GOLD} strokeWidth="1"
filter="url(#sg-glow)" strokeLinejoin="round" />
{/* Labels axe X */}
{xTicks.map((p, i) => {
const anchor = i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle';
return (
<text key={i} x={xScale(p.date)} y={H - 8}
textAnchor={anchor} fill={LABEL} fontSize="10"
fontFamily="system-ui,sans-serif">
{fmtAxisDate(p.date, range)}
</text>
);
})}
{/* Ligne verticale + point hover */}
{hover && (
<g>
<line x1={hover.x} y1={PAD.top} x2={hover.x} y2={PAD.top + plotH}
stroke="rgba(255,255,255,0.12)" strokeWidth="1" strokeDasharray="3 4" />
<circle cx={hover.x} cy={hover.y} r="4.5"
fill={GOLD} stroke={BG} strokeWidth="2" />
</g>
)}
</svg>
)}
{/* ── Tooltip flottant ── */}
{hover && tooltipStyle && (
<div style={tooltipStyle}>
<div className="sg-tooltip">
<span className="sg-tooltip-date">{tooltipDate}</span>
<span className="sg-tooltip-value">{fmtValueDisplay(hover.value)}</span>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,499 @@
import { useEffect, useState, useRef, useMemo } from 'react';
import { api } from '../api.js';
import { useInteretsChart } from '../context/InteretsChartContext.jsx';
import { fmtEUR, fmtPct } from '../utils/format.js';
const ICONS_BASE = '/api/icons-files/';
const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
function hexToRgba(hex, a) {
if (!hex || hex.length < 7) return `rgba(79,168,232,${a})`;
const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16);
return `rgba(${r},${g},${b},${a})`;
}
function ChevronDown({ size = 10 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9l6 6 6-6"/>
</svg>
);
}
/* ── Fusionne deux maps de remboursements ou projections ── */
function mergeMaps(mapA, mapB) {
const result = {};
const keys = new Set([...Object.keys(mapA || {}), ...Object.keys(mapB || {})]);
for (const k of keys) {
const a = mapA?.[k] || {};
const b = mapB?.[k] || {};
result[k] = {
interets_bruts: (a.interets_bruts || 0) + (b.interets_bruts || 0),
interets_nets: (a.interets_nets || 0) + (b.interets_nets || 0),
cashback: (a.cashback || 0) + (b.cashback || 0),
capital: (a.capital || 0) + (b.capital || 0),
interets_prevus: (a.interets_prevus || 0) + (b.interets_prevus || 0),
capital_prevu: (a.capital_prevu || 0) + (b.capital_prevu || 0),
};
}
return result;
}
export default function TableauInteretsPlateforme({ activeView, activeId, pfuRates, onCapitalMensuel, expandButton, onCellClick, activeCell }) {
const {
annee, setAnnee, availableYears,
inclureInterets, setInclureInterets,
inclureCapital, setInclureCapital,
inclureCashback, setInclureCashback,
netMode,
showActual, toggleActual,
showProjected, toggleProjected,
modeGlobal, toggleModeGlobal,
currentYear, currentMonth,
chartInterets, chartCapital, chartCashback,
} = useInteretsChart();
const [data, setData] = useState(null);
const [libIcons, setLibIcons] = useState({});
const [windowStart, setWindowStart] = useState(0);
const initializedRef = useRef(false);
/* ── Toggle consolidation détenteurs (clé partagée avec CapitalMensuelTable) ── */
const [groupByNom, setGroupByNom] = useState(() => {
try { return localStorage.getItem('cl_tip_group_by_nom') === 'true'; } catch { return false; }
});
const toggleGroupByNom = () => {
setGroupByNom(v => {
const next = !v;
try { localStorage.setItem('cl_tip_group_by_nom', String(next)); } catch {}
return next;
});
};
/* ── Icônes bibliothèque ─────────────────────────────────────── */
useEffect(() => {
api.get('/icons').then(rows => {
const m = {};
rows.forEach(r => { m[r.name] = r.filename; });
setLibIcons(m);
}).catch(() => {});
}, []);
/* ── Fenêtre années ──────────────────────────────────────────── */
const canPrev = windowStart > 0;
const canNext = windowStart + 3 < availableYears.length;
const visibleYears = availableYears.length ? availableYears.slice(windowStart, windowStart + 3) : [annee];
useEffect(() => {
if (!availableYears.length || initializedRef.current) return;
initializedRef.current = true;
const idx = availableYears.indexOf(annee);
const safe = idx >= 0 ? idx : availableYears.length - 1;
setWindowStart(Math.max(0, Math.min(availableYears.length - 3, safe - 1)));
}, [availableYears]);
/* ── Réduction PFU ───────────────────────────────────────────── */
const pfuReduction = useMemo(() => {
if (!pfuRates?.length) return 0;
const r = pfuRates.find(r => r.annee === annee)
?? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]);
return (r.prelev_sociaux + r.impot_revenu) / 100;
}, [pfuRates, annee]);
/* ── Fetch données ───────────────────────────────────────────── */
useEffect(() => {
if (modeGlobal) { setData(null); onCapitalMensuel?.([]); return; }
const params = { annee, ...(activeView === 'all' ? { scope: 'all' } : {}) };
api.get('/dashboard/interets-par-plateforme', params)
.then(d => { setData(d); onCapitalMensuel?.(d.capitalMensuel ?? []); })
.catch(() => {});
}, [annee, activeView, activeId, modeGlobal]);
/* ── Helpers affichage ───────────────────────────────────────── */
const AppIcon = ({ name, size = 28, active = false }) => {
const filename = libIcons[name];
if (filename) return (
<img src={ICONS_BASE + filename} className="app-lib-icon" width={size} height={size}
aria-hidden="true"
style={{ opacity: active ? 1 : 0.35, display: 'block', transition: 'opacity .15s' }} />
);
return <span style={{ width: size, height: size, display: 'block', borderRadius: 4,
background: 'var(--text-muted)', opacity: active ? 0.55 : 0.2, transition: 'opacity .15s' }} />;
};
const plateformes = data?.plateformes ?? [];
const capitalMensuel = data?.capitalMensuel ?? [];
// N'afficher le détenteur que s'il y en a plusieurs distincts (pattern multiDetenteur)
const multiDetenteur = new Set(plateformes.map(p => p.detenteur_nom).filter(Boolean)).size > 1;
/* ── Consolidation par nom si demandée ──────────────────────── */
const displayPlateformes = useMemo(() => {
if (!groupByNom || !multiDetenteur) return plateformes;
const byNom = {};
for (const plat of plateformes) {
if (!byNom[plat.nom]) {
byNom[plat.nom] = {
...plat,
id: plat.nom,
detenteur_nom: null,
rembourses: { ...plat.rembourses },
projections: { ...plat.projections },
};
} else {
byNom[plat.nom].rembourses = mergeMaps(byNom[plat.nom].rembourses, plat.rembourses);
byNom[plat.nom].projections = mergeMaps(byNom[plat.nom].projections, plat.projections);
}
}
return Object.values(byNom);
}, [plateformes, groupByNom, multiDetenteur]);
if (modeGlobal || !data || plateformes.length === 0) {
return null;
}
/* ── Valeurs par plateforme/mois ────────────────────────────────
* getCellValue : pour l'affichage (interets + cashback + capital selon toggles)
* getPerfValue : pour la performance (interets + cashback uniquement, jamais capital)
* ─────────────────────────────────────────────────────────────── */
const buildValue = (plat, mIdx, { withCapital }) => {
const m = mIdx + 1;
const moisStr = `${annee}-${String(m).padStart(2, '0')}`;
const isFuture = annee > currentYear || (annee === currentYear && m > currentMonth);
const isCurrent = annee === currentYear && m === currentMonth;
if (isFuture) {
if (!showProjected) return null;
const proj = plat.projections[moisStr];
if (!proj) return null;
let v = 0;
if (inclureInterets) v += netMode ? proj.interets_prevus * (1 - pfuReduction) : proj.interets_prevus;
if (withCapital && inclureCapital) v += proj.capital_prevu ?? 0;
return v > 0 ? { value: v, projected: true } : null;
}
if (isCurrent) {
const remb = plat.rembourses[moisStr];
const proj = plat.projections[moisStr];
let real = 0;
if (showActual && remb) {
if (inclureInterets) real += netMode ? remb.interets_nets : remb.interets_bruts;
if (inclureCashback) real += remb.cashback ?? 0;
if (withCapital && inclureCapital) real += remb.capital ?? 0;
}
let projAmt = 0;
// Les projections backend sont déjà filtrées NOT EXISTS par investissement → pas de double-comptage
if (showProjected && proj) {
if (inclureInterets) projAmt += netMode ? proj.interets_prevus * (1 - pfuReduction) : proj.interets_prevus;
if (withCapital && inclureCapital) projAmt += proj.capital_prevu ?? 0;
}
const val = real + projAmt;
return val > 0 ? { value: val, projected: projAmt > 0 } : null;
}
// Mois passé
if (!showActual) return null;
const remb = plat.rembourses[moisStr];
if (!remb) return null;
let v = 0;
if (inclureInterets) v += netMode ? remb.interets_nets : remb.interets_bruts;
if (inclureCashback) v += remb.cashback ?? 0;
if (withCapital && inclureCapital) v += remb.capital ?? 0;
return v > 0 ? { value: v, projected: false } : null;
};
const getCellValue = (plat, mIdx) => buildValue(plat, mIdx, { withCapital: true });
const getPerfValue = (plat, mIdx) => buildValue(plat, mIdx, { withCapital: false });
/* ── Grille ──────────────────────────────────────────────────── */
const grid = displayPlateformes.map(plat => ({
...plat,
months: Array.from({ length: 12 }, (_, i) => getCellValue(plat, i)),
}));
const monthTotals = Array.from({ length: 12 }, (_, i) =>
grid.reduce((s, row) => s + (row.months[i]?.value ?? 0), 0));
const platTotals = grid.map(row =>
row.months.reduce((s, v) => s + (v?.value ?? 0), 0));
const grandTotal = monthTotals.reduce((s, v) => s + v, 0);
/* Totaux pour la performance : intérêts + cashback uniquement (sans capital) */
const perfMonthTotals = Array.from({ length: 12 }, (_, i) =>
displayPlateformes.reduce((s, plat) => s + (getPerfValue(plat, i)?.value ?? 0), 0));
const perfGrandTotal = perfMonthTotals.reduce((s, v) => s + v, 0);
/* ── Capital et performances ─────────────────────────────────── */
const capitalValues = capitalMensuel.map(c => c.capital);
const nonZeroCap = capitalValues.filter(v => v > 0);
const avgCapital = nonZeroCap.length ? nonZeroCap.reduce((s, v) => s + v, 0) / nonZeroCap.length : 0;
const lastCapital = [...capitalValues].reverse().find(v => v > 0) ?? avgCapital;
const perfMensuelle = Array.from({ length: 12 }, (_, i) =>
capitalValues[i] > 0 ? perfMonthTotals[i] / capitalValues[i] : null);
const perfAnnualisee = perfMensuelle.map(p => p !== null ? p * 12 : null);
const perfAnnTotale = lastCapital > 0 ? perfGrandTotal / lastCapital : null;
/* ── Label total header ──────────────────────────────────────── */
const activeTypes = [
inclureInterets && { color: chartInterets, label: netMode ? 'Intérêts nets' : 'Intérêts bruts' },
inclureCapital && { color: chartCapital, label: 'Capital' },
inclureCashback && { color: chartCashback, label: 'Cashback' },
].filter(Boolean);
/* ── Rendu ───────────────────────────────────────────────────── */
return (
<div className="solde-chart-wrap" style={{ padding: '24px 24px 16px', marginBottom: 24 }}>
{/* ── Header identique au bar chart ── */}
<div className="solde-chart-header">
<div className="solde-chart-info">
<div style={{ display:'flex', alignItems:'center', gap:5, flexWrap:'wrap', marginBottom:2 }}>
{inclureInterets && (
<span style={{ display:'inline-flex', alignItems:'center', gap:4,
background:hexToRgba(chartInterets,0.12), borderRadius:5, padding:'3px 8px' }}>
<span style={{ width:7, height:7, borderRadius:2, background:chartInterets, flexShrink:0 }}/>
<span style={{ fontSize:13, color:chartInterets, fontWeight:600 }}>
{netMode ? 'Intérêts nets' : 'Intérêts bruts'}
</span>
</span>
)}
{inclureCapital && (
<span style={{ display:'inline-flex', alignItems:'center', gap:4,
background:hexToRgba(chartCapital,0.12), borderRadius:5, padding:'3px 8px' }}>
<span style={{ width:7, height:7, borderRadius:2, background:chartCapital, flexShrink:0 }}/>
<span style={{ fontSize:13, color:chartCapital, fontWeight:600 }}>Capital</span>
</span>
)}
{inclureCashback && (
<span style={{ display:'inline-flex', alignItems:'center', gap:4,
background:hexToRgba(chartCashback,0.12), borderRadius:5, padding:'3px 8px' }}>
<span style={{ width:7, height:7, borderRadius:2, background:chartCashback, flexShrink:0 }}/>
<span style={{ fontSize:13, color:chartCashback, fontWeight:600 }}>Cashback</span>
</span>
)}
{!inclureInterets && !inclureCapital && !inclureCashback && (
<span style={{ fontSize:13, color:'var(--text-muted)' }}></span>
)}
<span style={{ fontSize:13, color:'var(--text-muted)' }}>· {annee}</span>
</div>
<div className="solde-chart-value">{fmtEUR(grandTotal)}</div>
</div>
<div className="solde-chart-controls">
{/* Bouton intérêts */}
<button
title={inclureInterets ? 'Intérêts inclus — cliquer pour exclure' : 'Cliquer pour inclure les intérêts'}
onClick={() => setInclureInterets(v => !v)}
style={{ background: inclureInterets ? hexToRgba(chartInterets,0.13) : 'none',
border:'1px solid '+(inclureInterets ? chartInterets : 'transparent'),
borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center',
transition:'background .15s,border-color .15s', marginRight:2 }}>
<AppIcon name="interets" active={inclureInterets} />
</button>
{/* Bouton capital */}
<button
title={inclureCapital ? 'Capital inclus — cliquer pour exclure' : 'Cliquer pour inclure le capital'}
onClick={() => setInclureCapital(v => !v)}
style={{ background: inclureCapital ? hexToRgba(chartCapital,0.13) : 'none',
border:'1px solid '+(inclureCapital ? chartCapital : 'transparent'),
borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center',
transition:'background .15s,border-color .15s', marginRight:2 }}>
<AppIcon name="capital" active={inclureCapital} />
</button>
{/* Bouton cashback */}
<button
title={inclureCashback ? 'Cashback inclus — cliquer pour exclure' : 'Cliquer pour inclure le cashback'}
onClick={() => setInclureCashback(v => !v)}
style={{ background: inclureCashback ? hexToRgba(chartCashback,0.13) : 'none',
border:'1px solid '+(inclureCashback ? chartCashback : 'transparent'),
borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center',
transition:'background .15s,border-color .15s', marginRight:2 }}>
<AppIcon name="cashback" active={inclureCashback} />
</button>
{/* Sélecteur d'années */}
<div className="solde-chart-ranges">
<button className="solde-range-btn"
onClick={() => setWindowStart(w => Math.max(0, w-1))}
disabled={!canPrev} style={{ opacity: canPrev ? 1 : 0.3 }}></button>
{visibleYears.map(y => (
<button key={y} className={`solde-range-btn${annee === y ? ' active' : ''}`}
onClick={() => setAnnee(y)}>
{y}
</button>
))}
<button className="solde-range-btn"
onClick={() => setWindowStart(w => Math.min(Math.max(0, availableYears.length - 3), w+1))}
disabled={!canNext} style={{ opacity: canNext ? 1 : 0.3 }}></button>
<button className={`solde-range-btn${modeGlobal ? ' active' : ''}`}
onClick={() => toggleModeGlobal()}>
TOUT
</button>
{expandButton}
</div>
</div>
</div>
{/* ── Tableau ── */}
<div style={{ overflowX: 'auto', marginTop: 20, position: 'relative', zIndex: 0 }}>
<table className="tip-table">
<thead>
<tr>
<th className="tip-th-empty" />
<th className="tip-th-year" colSpan={12}>{annee}</th>
<th className="tip-th-empty" />
<th className="tip-th-empty" />
</tr>
<tr>
<th className="tip-th-name tip-th-name-amber">
<span style={{ display:'flex', alignItems:'center', gap:6 }}>
Plateforme
{multiDetenteur && (
<button
onClick={() => toggleGroupByNom()}
title={groupByNom ? 'Vue consolidée — cliquer pour détailler par détenteur' : 'Vue détaillée — cliquer pour consolider par plateforme'}
style={{
display:'inline-flex', alignItems:'center', gap:3,
background:'rgba(255,255,255,0.15)', border:'1px solid rgba(255,255,255,0.3)',
borderRadius:4, padding:'2px 5px', cursor:'pointer',
fontSize:10, fontWeight:600, color:'#fff', letterSpacing:'.03em',
lineHeight:1.4, whiteSpace:'nowrap', transition:'background .15s',
}}>
{groupByNom ? 'Consolidé' : 'Détaillé'}
<span style={{ display:'inline-flex', transition:'transform .2s', transform: groupByNom ? 'rotate(180deg)' : 'rotate(0deg)' }}>
<ChevronDown size={9} />
</span>
</button>
)}
</span>
</th>
{MOIS_LONG.map((m, i) => (
<th key={m} className={`tip-th-month${annee === currentYear && i === currentMonth - 1 ? ' tip-th-month-current' : ''}`}>{m}</th>
))}
<th className="tip-th-total">Total</th>
<th className="tip-th-avg">Moy. mensuelle</th>
</tr>
</thead>
<tbody>
{grid.map((plat, pi) => (
<tr key={plat.id} className="tip-row-plat">
<td className="tip-td-name">
{plat.nom}
{!groupByNom && multiDetenteur && plat.detenteur_nom && (
<span style={{ marginLeft: 6, fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>
{plat.detenteur_nom}
</span>
)}
</td>
{plat.months.map((v, mi) => {
const isCurrent = annee === currentYear && mi === currentMonth - 1;
const cellKey = `${plat.id}:${annee}-${String(mi + 1).padStart(2,'0')}`;
const isActive = activeCell?.key === cellKey;
const clickable = !!v;
return (
<td key={mi}
className={`tip-td-num${v?.projected ? ' tip-projected' : ''}${isCurrent ? ' tip-col-current' : ''}${isActive ? ' tip-td-active' : ''}${clickable ? ' tip-td-clickable' : ''}`}
onClick={() => clickable && onCellClick && onCellClick({
key: cellKey,
platId: plat.id,
platNom: plat.nom,
annee,
mois: String(mi + 1).padStart(2, '0'),
moisLabel: MOIS_LONG[mi],
})}
>
{v ? fmtEUR(v.value) : <span className="tip-dash"></span>}
</td>
);
})}
<td className="tip-td-total">
{platTotals[pi] > 0 ? fmtEUR(platTotals[pi]) : <span className="tip-dash"></span>}
</td>
<td className="tip-td-avg">
{platTotals[pi] > 0 ? fmtEUR(platTotals[pi] / 12) : <span className="tip-dash"></span>}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="tip-footer-total">
<td className="tip-td-name">Toutes les plateformes</td>
{monthTotals.map((v, i) => (
<td key={i} className={`tip-td-num${annee === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`}>
{v > 0 ? fmtEUR(v) : <span className="tip-dash"></span>}
</td>
))}
<td className="tip-td-total">{fmtEUR(grandTotal)}</td>
<td className="tip-td-avg">{grandTotal > 0 ? fmtEUR(grandTotal / 12) : <span className="tip-dash"></span>}</td>
</tr>
<tr className="tip-footer-capital">
<td className="tip-td-name">Capital investi</td>
{capitalValues.map((v, i) => (
<td key={i} className={`tip-td-num${annee === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`}>
{v > 0 ? fmtEUR(v) : <span className="tip-dash"></span>}
</td>
))}
<td className="tip-td-total">{lastCapital > 0 ? fmtEUR(lastCapital) : <span className="tip-dash"></span>}</td>
<td className="tip-td-void" />
</tr>
<tr className="tip-footer-perf">
<td className="tip-td-name">{netMode ? "Performance nette mensuelle" : "Performance brute mensuelle"}</td>
{perfMensuelle.map((v, i) => (
<td key={i} className={`tip-td-num${annee === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`}>
{v !== null ? fmtPct(v * 100) : <span className="tip-dash"></span>}
</td>
))}
<td className="tip-td-total">
{perfAnnTotale !== null ? fmtPct((perfAnnTotale / 12) * 100) : <span className="tip-dash"></span>}
</td>
<td className="tip-td-void" />
</tr>
<tr className="tip-footer-perf">
<td className="tip-td-name">{netMode ? "Performance nette annualisée" : "Performance brute annualisée"}</td>
{perfAnnualisee.map((v, i) => (
<td key={i} className={`tip-td-num${annee === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`}>
{v !== null ? fmtPct(v * 100) : <span className="tip-dash"></span>}
</td>
))}
<td className="tip-td-total">
{perfAnnTotale !== null ? fmtPct(perfAnnTotale * 100) : <span className="tip-dash"></span>}
</td>
<td className="tip-td-void" />
</tr>
</tfoot>
</table>
</div>
{/* ── Sélecteur Reçu / Projeté ── */}
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginTop:12, gap:12, flexWrap:'wrap' }}>
<div style={{
display:'inline-flex',
background:'#f0f0f0',
borderRadius:8,
padding:3,
gap:2,
flexShrink:0,
}}>
{[
{ key:'actual', label:'Reçu', active:showActual, toggle:toggleActual },
{ key:'projected', label:'Projeté', active:showProjected, toggle:toggleProjected },
].map(btn => (
<button key={btn.key} onClick={() => btn.toggle()} style={{
border:'none', cursor:'pointer', padding:'5px 14px', borderRadius:6,
fontSize:'var(--fs-sm)',
fontWeight: btn.active ? 600 : 400,
background: btn.active ? '#ffffff' : 'transparent',
color: btn.active ? '#1a1a2e' : '#9ca3af',
boxShadow: btn.active ? '0 1px 3px rgba(0,0,0,0.13)' : 'none',
transition: 'all .15s', lineHeight: 1.4,
}}>{btn.label}</button>
))}
</div>
</div>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { useTheme } from '../context/ThemeContext.jsx';
const OPTIONS = [
{ mode: 'light', icon: '☀', label: 'Clair' },
{ mode: 'dark', icon: '☾', label: 'Sombre' },
{ mode: 'system', icon: '◐', label: 'Système' },
];
export default function ThemeSwitcher() {
const { mode, setMode } = useTheme();
return (
<div className="theme-switcher" role="group" aria-label="Thème">
{OPTIONS.map(o => (
<button
key={o.mode}
type="button"
className={mode === o.mode ? 'active' : ''}
onClick={() => setMode(o.mode)}
title={o.label}
aria-pressed={mode === o.mode}
>
<span aria-hidden="true">{o.icon}</span>
<span>{o.label}</span>
</button>
))}
</div>
);
}
+353
View File
@@ -0,0 +1,353 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';
import { useUi } from '../context/UiContext.jsx';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { memberInitials, memberLabel } from '../utils/format.js';
/* ── Icons ───────────────────────────────────────────────────── */
function IconUser() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>;
}
function IconLogout() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>;
}
function IconAdmin() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="9" cy="6" r="3"/><path d="M2 16c0-3.3 3.1-6 7-6"/><path d="M14 13l-1.5 1.5L14 16"/><circle cx="15.5" cy="14.5" r="2.5"/></svg>;
}
function IconSettings() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><line x1="4" y1="6" x2="20" y2="6"/><circle cx="8" cy="6" r="2" fill="var(--user-menu-bg, #1e2d4a)"/><line x1="4" y1="12" x2="20" y2="12"/><circle cx="16" cy="12" r="2" fill="var(--user-menu-bg, #1e2d4a)"/><line x1="4" y1="18" x2="20" y2="18"/><circle cx="10" cy="18" r="2" fill="var(--user-menu-bg, #1e2d4a)"/></svg>;
}
function IconAide() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>;
}
function IconChevronRight() {
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M9 18l6-6-6-6"/></svg>;
}
function IconChevronUp({ open }) {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ transition: 'transform .2s', transform: open ? 'rotate(0deg)' : 'rotate(180deg)', flexShrink: 0 }}
aria-hidden="true">
<path d="M18 15l-6-6-6 6"/>
</svg>
);
}
function IconCheck() {
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>;
}
function IconTeam() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>;
}
/* ── Avatars ─────────────────────────────────────────────────── */
function UserAvatar({ user, size = 36 }) {
const initials = user?.display_name
? user.display_name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
: (user?.email || '?')[0].toUpperCase();
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: 'linear-gradient(135deg, #1e40af 0%, #1e3a8a 100%)',
border: '2px solid rgba(74,222,128,.5)',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 700, fontSize: Math.round(size * 0.38), flexShrink: 0,
letterSpacing: '.02em', userSelect: 'none',
}}>
{initials}
</div>
);
}
function MemberAvatar({ member, size = 28 }) {
const isEntreprise = member?.type === 'entreprise';
const bg = isEntreprise
? 'linear-gradient(135deg, #3730a3, #4338ca)'
: 'linear-gradient(135deg, #1e3a8a, #1e40af)';
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: bg, color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 700, fontSize: Math.round(size * 0.38), flexShrink: 0,
letterSpacing: '.02em', userSelect: 'none',
}}>
{memberInitials(member)}
</div>
);
}
function TeamBadge({ size = 28 }) {
return (
<div className="team-badge" style={{
width: size, height: size, borderRadius: '50%',
background: '#1e3a8a',
border: '1.5px solid rgba(255,255,255,.25)',
color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<IconTeam />
</div>
);
}
/* ── Composant principal ─────────────────────────────────────── */
export default function UserMenu() {
const { user, logout, isAdmin } = useAuth();
const { sidebarCollapsed } = useUi();
const { investisseurs, activeView, activeViewMember, setActiveView } = useInvestisseur();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [subOpen, setSubOpen] = useState(false);
const [popupStyle, setPopupStyle] = useState({});
const [subStyle, setSubStyle] = useState({});
const triggerRef = useRef(null);
const popupRef = useRef(null);
const subRef = useRef(null);
const closeTimer = useRef(null);
const famille = investisseurs.filter(i => i.type !== 'entreprise');
const entreprises = investisseurs.filter(i => i.type === 'entreprise');
const POPUP_W = 250;
const SUB_W = 230;
/* ── Calcul position (fixed = échappe overflow:hidden) ────── */
const computePosition = useCallback(() => {
if (!triggerRef.current) return;
const r = triggerRef.current.getBoundingClientRect();
let mainLeft, mainBottom, mainWidth;
if (sidebarCollapsed) {
mainLeft = r.right + 8;
mainBottom = window.innerHeight - r.bottom;
mainWidth = POPUP_W;
} else {
mainLeft = r.left;
mainBottom = window.innerHeight - r.top + 6;
mainWidth = r.width;
}
setPopupStyle({
position: 'fixed', left: mainLeft, bottom: mainBottom,
width: mainWidth, top: 'auto', right: 'auto',
});
setSubStyle({
position: 'fixed',
left: mainLeft + (sidebarCollapsed ? POPUP_W : mainWidth) + 6,
bottom: mainBottom,
width: SUB_W, top: 'auto', right: 'auto',
});
}, [sidebarCollapsed]);
const openMenu = useCallback(() => { computePosition(); setOpen(true); }, [computePosition]);
const closeMenu = useCallback(() => { setOpen(false); setSubOpen(false); }, []);
const clearClose = () => clearTimeout(closeTimer.current);
const scheduleClose = () => { closeTimer.current = setTimeout(closeMenu, 200); };
/* Fermeture clic extérieur */
useEffect(() => {
if (!open) return;
const onDown = (e) => {
const inTrigger = triggerRef.current?.contains(e.target);
const inPopup = popupRef.current?.contains(e.target);
const inSub = subRef.current?.contains(e.target);
if (!inTrigger && !inPopup && !inSub) closeMenu();
};
document.addEventListener('mousedown', onDown);
return () => document.removeEventListener('mousedown', onDown);
}, [open, closeMenu]);
/* Recalcul si sidebar change */
useEffect(() => { if (open) computePosition(); }, [sidebarCollapsed, open, computePosition]);
/* Handlers ouverture */
const onWrapEnter = () => { if (sidebarCollapsed) { clearClose(); openMenu(); } };
const onWrapLeave = () => { if (sidebarCollapsed) scheduleClose(); };
const onTriggerClick = () => { if (!sidebarCollapsed) { open ? closeMenu() : openMenu(); } };
const go = (path) => { closeMenu(); navigate(path); };
const handleLogout = () => { closeMenu(); logout(); navigate('/login'); };
const selectView = (v) => {
setActiveView(v);
closeMenu();
};
/* ── Libellés ────────────────────────────────────────────── */
const viewLabel = activeView === 'all'
? 'Famille et entreprises'
: (activeViewMember ? memberLabel(activeViewMember) : 'Famille et entreprises');
const TriggerBadge = activeView === 'all'
? <TeamBadge size={30} />
: <MemberAvatar member={activeViewMember} size={30} />;
return (
<div
className="user-menu-wrap"
onMouseEnter={onWrapEnter}
onMouseLeave={onWrapLeave}
>
{/* ── Main Popup (portail → échappe tout stacking context) ── */}
{open && createPortal(
<div
ref={popupRef}
id="user-menu-popup"
className="user-menu-popup"
style={popupStyle}
role="menu"
onMouseEnter={clearClose}
onMouseLeave={() => { if (sidebarCollapsed) scheduleClose(); }}
>
{/* En-tête compte utilisateur */}
<div className="user-menu-header">
<UserAvatar user={user} size={40} />
<div style={{ overflow: 'hidden', flex: 1 }}>
{user?.display_name && <div className="user-menu-name">{user.display_name}</div>}
<div className="user-menu-email">{user?.email}</div>
</div>
</div>
<div className="user-menu-sep" />
{/* Section Vue du profil */}
<div className="user-menu-section-header">Vue du profil</div>
<button
className={`user-menu-item user-menu-vue-row${subOpen ? ' active' : ''}`}
role="menuitem"
onClick={() => setSubOpen(s => !s)}
>
{activeView === 'all'
? <TeamBadge size={24} />
: <MemberAvatar member={activeViewMember} size={24} />
}
<span className="user-menu-vue-name">{viewLabel}</span>
<span style={{ color: '#4a6490', display: 'flex' }}><IconChevronRight /></span>
</button>
<div className="user-menu-sep" />
<button className="user-menu-item" role="menuitem" onClick={() => go('/compte')}>
<IconUser /> Mon compte
</button>
{isAdmin && (
<button className="user-menu-item" role="menuitem" onClick={() => go('/admin')}>
<IconAdmin /> Administration
</button>
)}
<button className="user-menu-item" role="menuitem" onClick={() => go('/settings')}>
<IconSettings /> Paramètres
</button>
<button className="user-menu-item" role="menuitem" onClick={() => go('/aide')}>
<IconAide /> Aide
</button>
<div className="user-menu-sep" />
<button className="user-menu-item danger" role="menuitem" onClick={handleLogout}>
<IconLogout /> Se déconnecter
</button>
</div>,
document.body
)}
{/* ── Sub-panel Vue du profil (portail) ───────────────────── */}
{open && subOpen && createPortal(
<div
ref={subRef}
className="user-menu-subpanel"
style={subStyle}
onMouseEnter={clearClose}
onMouseLeave={() => { if (sidebarCollapsed) scheduleClose(); }}
>
<div className="user-menu-subpanel-title">Vue du profil</div>
{/* Famille et entreprises (vue agrégée) */}
<button
className={`user-menu-profile-item${activeView === 'all' ? ' selected' : ''}`}
onClick={() => selectView('all')}
>
<TeamBadge size={26} />
<span className="user-menu-profile-name">Famille et entreprises</span>
{activeView === 'all' && <IconCheck />}
</button>
{/* Membres famille */}
{famille.length > 0 && (
<>
<div className="user-menu-subpanel-section">
{famille.length > 1 ? 'Profils' : 'Profil'}
</div>
{famille.map(m => (
<button
key={m.id}
className={`user-menu-profile-item${String(activeView) === String(m.id) ? ' selected' : ''}`}
onClick={() => selectView(String(m.id))}
>
<MemberAvatar member={m} size={26} />
<span className="user-menu-profile-name">{memberLabel(m)}</span>
{String(activeView) === String(m.id) && <IconCheck />}
</button>
))}
</>
)}
{/* Entreprises */}
{entreprises.length > 0 && (
<>
<div className="user-menu-subpanel-section">
{entreprises.length > 1 ? 'Entreprises' : 'Entreprise'}
</div>
{entreprises.map(m => (
<button
key={m.id}
className={`user-menu-profile-item${String(activeView) === String(m.id) ? ' selected' : ''}`}
onClick={() => selectView(String(m.id))}
>
<MemberAvatar member={m} size={26} />
<span className="user-menu-profile-name">{memberLabel(m)}</span>
{String(activeView) === String(m.id) && <IconCheck />}
</button>
))}
</>
)}
<div className="user-menu-sep" style={{ margin: '6px 0' }} />
<button
className="user-menu-profile-manage"
onClick={() => go('/compte?section=famille')}
>
Gérer les profils
</button>
</div>,
document.body
)}
{/* ── Déclencheur (bas de sidebar) ─────────────────────── */}
<button
ref={triggerRef}
className="user-menu-trigger"
onClick={onTriggerClick}
aria-haspopup="menu"
aria-expanded={open}
title={viewLabel}
>
{TriggerBadge}
{!sidebarCollapsed && (
<div className="user-menu-trigger-info">
<span className="user-menu-trigger-name">{viewLabel}</span>
</div>
)}
{!sidebarCollapsed && <IconChevronUp open={open} />}
</button>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { api } from '../api.js';
const AuthCtx = createContext(null);
export function AuthProvider({ children }) {
const [token, setToken] = useState(() => localStorage.getItem('cl_token'));
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!token) { setLoading(false); return; }
api.get('/auth/me')
.then(({ user }) => setUser(user))
.catch(() => logout())
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
const login = async (email, password) => {
const r = await api.post('/auth/login', { email, password });
localStorage.setItem('cl_token', r.token);
setToken(r.token);
setUser(r.user);
return r.user;
};
const register = async (email, password, displayName) => {
const r = await api.post('/auth/register', { email, password, displayName });
localStorage.setItem('cl_token', r.token);
setToken(r.token);
setUser(r.user);
return r.user;
};
const logout = () => {
localStorage.removeItem('cl_token');
localStorage.removeItem('cl_investisseur_id');
setToken(null);
setUser(null);
};
const updateUser = async (payload) => {
const r = await api.put('/auth/me', payload);
setUser(r.user);
// If backend issued a fresh token (email changed), persist it
if (r.token) {
localStorage.setItem('cl_token', r.token);
setToken(r.token);
}
return r.user;
};
const isAdmin = user?.role === 'admin';
return (
<AuthCtx.Provider value={{ token, user, loading, login, register, logout, updateUser, isAdmin }}>
{children}
</AuthCtx.Provider>
);
}
export const useAuth = () => useContext(AuthCtx);
@@ -0,0 +1,202 @@
import { createContext, useContext, useEffect, useState, useMemo, useCallback } from 'react';
import { api } from '../api.js';
import { useUi } from './UiContext.jsx';
const Ctx = createContext(null);
const MOIS = ['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc'];
const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
export function InteretsChartProvider({ children, netMode, pfuRates, activeView, activeId }) {
const { chartInterets, chartCapital, chartCashback } = useUi();
const today = new Date();
const currentYear = today.getFullYear();
const currentMonth = today.getMonth() + 1;
// ── État partagé ────────────────────────────────────────────────
const [annee, setAnnee] = useState(currentYear);
const [inclureInterets, setInclureInterets] = useState(true);
const [inclureCapital, setInclureCapital] = useState(false);
const [inclureCashback, setInclureCashback] = useState(false);
const [selectedMonth, setSelectedMonth] = useState(null); // 0-11 | null
const [rawData, setRawData] = useState({ rembourses: [], projections: [], annees: [] });
// ── Mode global (TOUT) ──────────────────────────────────────────
const [modeGlobal, setModeGlobal] = useState(false);
const [selectedYear, setSelectedYear] = useState(null); // year number | null
const [rawDataGlobal, setRawDataGlobal] = useState({ rembourses: [], projections: [], annees: [] });
const toggleModeGlobal = useCallback(() => {
setModeGlobal(prev => {
if (!prev) setSelectedYear(null); // reset sélection en entrant dans le mode
return !prev;
});
}, []);
// Filtres reçu / projeté (force reçu si les deux seraient désactivés)
const [showActual, setShowActualRaw] = useState(true);
const [showProjected, setShowProjected] = useState(true);
const setShowActual = useCallback((val) => {
setShowActualRaw(prev => (val === false || val === prev ? (!prev && !showProjected ? true : val === false ? false : !prev) : val));
}, [showProjected]);
const toggleActual = useCallback(() => setShowActualRaw(prev => (!prev && !showProjected) ? true : !prev), [showProjected]);
const toggleProjected = useCallback(() => setShowProjected(prev => !prev), []);
const selectActualOnly = useCallback(() => { setShowActualRaw(true); setShowProjected(false); }, []);
const selectProjectedOnly = useCallback(() => { setShowActualRaw(false); setShowProjected(true); }, []);
const setActualProjected = useCallback((actual, projected) => { setShowActualRaw(actual); setShowProjected(projected); }, []);
// ── Fetch mensuel ────────────────────────────────────────────────
useEffect(() => {
const params = { annee, ...(activeView === 'all' ? { scope: 'all' } : {}) };
api.get('/dashboard/interets-mensuels', params)
.then(res => setRawData(res))
.catch(() => {});
}, [annee, activeView, activeId]);
// ── Fetch annuel (global) ────────────────────────────────────────
useEffect(() => {
const params = activeView === 'all' ? { scope: 'all' } : {};
api.get('/dashboard/interets-annuels', params)
.then(res => setRawDataGlobal(res))
.catch(() => {});
}, [activeView, activeId]);
// Réinitialiser la sélection au changement d'année
useEffect(() => { setSelectedMonth(null); }, [annee]);
// ── PFU helper ───────────────────────────────────────────────────
const getPfuReduction = useCallback((year) => {
if (!pfuRates?.length) return 0;
const r = pfuRates.find(r => r.annee === year)
?? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]);
return (r.prelev_sociaux + r.impot_revenu) / 100;
}, [pfuRates]);
// ── Calcul mensuel ───────────────────────────────────────────────
const months = useMemo(() => {
const reduction = getPfuReduction(annee);
return Array.from({ length: 12 }, (_, i) => {
const m = i + 1;
const moisStr = `${annee}-${String(m).padStart(2,'0')}`;
const isCurrent = annee === currentYear && m === currentMonth;
const isFuture = annee > currentYear || (annee === currentYear && m > currentMonth);
const rembData = rawData.rembourses?.find(r => r.mois === moisStr);
const projData = rawData.projections?.find(p => p.mois === moisStr);
const rawInterets = rembData ? (netMode ? (rembData.interets_nets||0) : (rembData.interets_bruts||0)) : 0;
const rawInteretsProj = projData && (isCurrent||isFuture)
? (netMode ? (projData.interets_prevus||0)*(1-reduction) : (projData.interets_prevus||0)) : 0;
const cashbackAmt = inclureCashback ? (rembData?.cashback||0) : 0;
const capitalAmt = inclureCapital ? (rembData?.capital||0) : 0;
const interetsAmt = inclureInterets ? rawInterets : 0;
const capitalProjAmt = inclureCapital && projData && (isCurrent||isFuture) ? (projData.capital_prevu||0) : 0;
const interetsProjAmt= inclureInterets ? rawInteretsProj : 0;
return {
m, idx: i,
label: MOIS[i], labelLong: MOIS_LONG[i],
actual: capitalAmt + cashbackAmt + interetsAmt,
projected: capitalProjAmt + interetsProjAmt,
total: capitalAmt + cashbackAmt + interetsAmt + capitalProjAmt + interetsProjAmt,
isCurrentMonth: isCurrent, isFuture,
cashbackAmt, capitalAmt, interetsAmt, capitalProjAmt, interetsProjAmt,
};
});
}, [rawData, netMode, getPfuReduction, annee, currentYear, currentMonth,
inclureInterets, inclureCapital, inclureCashback]);
const annualTotal = useMemo(() => months.reduce((s, m) => s + m.total, 0), [months]);
const availableYears = rawData.annees ?? [];
// ── Calcul annuel (mode global) ──────────────────────────────────
const years = useMemo(() => {
// Collecter toutes les années connues (rembourses + projections + annees)
const allKnown = new Set([
...(rawDataGlobal.annees ?? []),
...(rawDataGlobal.rembourses ?? []).map(r => r.annee),
...(rawDataGlobal.projections ?? []).map(p => p.annee),
]);
// Toujours inclure l'année courante
allKnown.add(currentYear);
const allSorted = [...allKnown].map(Number).sort((a, b) => a - b);
// Algorithme de fenêtrage
const pastYears = allSorted.filter(y => y < currentYear);
const futureYears = allSorted.filter(y => y > currentYear);
const pastCount = Math.min(pastYears.length, 8); // currentYear en position max 9
const pastSlice = pastYears.slice(-pastCount);
const usedSlots = pastCount + 1; // +1 for currentYear
const projCount = Math.max(3, 12 - usedSlots);
const futureSlice = futureYears.slice(0, projCount);
const visibleYears = [...pastSlice, currentYear, ...futureSlice];
return visibleYears.map(y => {
const isCurrent = y === currentYear;
const isFuture = y > currentYear;
const reduction = getPfuReduction(y);
const rembData = rawDataGlobal.rembourses?.find(r => Number(r.annee) === y);
const projData = rawDataGlobal.projections?.find(p => Number(p.annee) === y);
const rawInterets = rembData ? (netMode ? (rembData.interets_nets||0) : (rembData.interets_bruts||0)) : 0;
const rawInteretsProj = projData
? (netMode ? (projData.interets_prevus||0)*(1-reduction) : (projData.interets_prevus||0)) : 0;
const cashbackAmt = inclureCashback ? (rembData?.cashback||0) : 0;
const capitalAmt = inclureCapital ? (rembData?.capital||0) : 0;
const interetsAmt = inclureInterets ? rawInterets : 0;
const capitalProjAmt = inclureCapital ? (projData?.capital_prevu||0) : 0;
const interetsProjAmt = inclureInterets ? rawInteretsProj : 0;
return {
y,
label: String(y),
isCurrent, isFuture,
actual: capitalAmt + cashbackAmt + interetsAmt,
projected: capitalProjAmt + interetsProjAmt,
total: capitalAmt + cashbackAmt + interetsAmt + capitalProjAmt + interetsProjAmt,
cashbackAmt, capitalAmt, interetsAmt, capitalProjAmt, interetsProjAmt,
};
});
}, [rawDataGlobal, netMode, getPfuReduction, currentYear,
inclureInterets, inclureCapital, inclureCashback]);
const globalTotal = useMemo(() => years.reduce((s, y) => s + y.total, 0), [years]);
return (
<Ctx.Provider value={{
// Année
annee, setAnnee, availableYears,
// Toggles types
inclureInterets, setInclureInterets,
inclureCapital, setInclureCapital,
inclureCashback, setInclureCashback,
// Mois sélectionné (liaison bar ↔ donut)
selectedMonth, setSelectedMonth,
// Filtres reçu / projeté
showActual, toggleActual,
showProjected, toggleProjected,
selectActualOnly, selectProjectedOnly, setActualProjected,
// Mode global (TOUT)
modeGlobal, toggleModeGlobal,
selectedYear, setSelectedYear,
years, globalTotal,
// Données
months, annualTotal,
rawData, rawDataGlobal,
currentYear, currentMonth,
// Affichage
netMode,
// Couleurs palette
chartInterets, chartCapital, chartCashback,
}}>
{children}
</Ctx.Provider>
);
}
export const useInteretsChart = () => useContext(Ctx);
@@ -0,0 +1,72 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { api } from '../api.js';
import { useAuth } from './AuthContext.jsx';
const InvCtx = createContext(null);
const KEY_ID = 'cl_investisseur_id';
const KEY_VIEW = 'cl_active_view'; // 'all' | '<id>'
export function InvestisseurProvider({ children }) {
const { token } = useAuth();
const [investisseurs, setInvestisseurs] = useState([]);
const [activeId, setActiveIdState] = useState(() => Number(localStorage.getItem(KEY_ID)) || null);
const [activeView, setActiveViewState] = useState(() => localStorage.getItem(KEY_VIEW) || 'all');
const [loading, setLoading] = useState(false);
const reload = useCallback(async () => {
if (!token) return;
setLoading(true);
try {
const list = await api.get('/investisseurs');
setInvestisseurs(list);
// S'assurer que activeId pointe sur un membre valide
if (list.length && (!activeId || !list.find(i => i.id === activeId))) {
const first = list[0].id;
setActiveIdState(first);
localStorage.setItem(KEY_ID, String(first));
}
} finally {
setLoading(false);
}
}, [token, activeId]);
useEffect(() => {
if (token) reload();
else setInvestisseurs([]);
}, [token, reload]);
/** Changer le membre actif pour les requêtes (topbar) */
const setActive = (id) => {
setActiveIdState(id);
localStorage.setItem(KEY_ID, String(id));
};
/** Changer la vue du profil : 'all' ou id numérique d'un investisseur */
const setActiveView = (v) => {
setActiveViewState(v);
localStorage.setItem(KEY_VIEW, String(v));
// Si on sélectionne un membre précis → l'activer aussi pour les requêtes
if (v !== 'all') {
setActiveIdState(Number(v));
localStorage.setItem(KEY_ID, String(v));
}
};
const active = investisseurs.find(i => i.id === activeId) || null;
const activeViewMember = activeView !== 'all'
? investisseurs.find(i => i.id === Number(activeView)) || null
: null;
return (
<InvCtx.Provider value={{
investisseurs, active, activeId, setActive,
activeView, activeViewMember, setActiveView,
reload, loading,
}}>
{children}
</InvCtx.Provider>
);
}
export const useInvestisseur = () => useContext(InvCtx);
+58
View File
@@ -0,0 +1,58 @@
import { createContext, useContext, useEffect, useMemo, useState, useCallback } from 'react';
/**
* ThemeContext
* - mode : 'light' | 'dark' | 'system' (préférence utilisateur, persistée)
* - resolved : 'light' | 'dark' (thème effectivement appliqué)
*
* Le thème effectif est appliqué via l'attribut data-theme sur <html>.
* En mode 'system', on écoute les changements du media query prefers-color-scheme.
*/
const ThemeCtx = createContext(null);
const STORAGE_KEY = 'cl_theme';
function getSystemTheme() {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyTheme(theme) {
const root = document.documentElement;
root.setAttribute('data-theme', theme);
// Inform the browser (form controls, scrollbars) of the active scheme
root.style.colorScheme = theme;
}
export function ThemeProvider({ children }) {
const [mode, setMode] = useState(() => localStorage.getItem(STORAGE_KEY) || 'system');
const [systemTheme, setSystemTheme] = useState(getSystemTheme);
// Listen to OS preference changes when in 'system' mode
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = (e) => setSystemTheme(e.matches ? 'dark' : 'light');
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);
const resolved = mode === 'system' ? systemTheme : mode;
// Apply the resolved theme to <html>
useEffect(() => { applyTheme(resolved); }, [resolved]);
const setThemeMode = useCallback((m) => {
setMode(m);
localStorage.setItem(STORAGE_KEY, m);
}, []);
const value = useMemo(
() => ({ mode, resolved, setMode: setThemeMode }),
[mode, resolved, setThemeMode],
);
return <ThemeCtx.Provider value={value}>{children}</ThemeCtx.Provider>;
}
export const useTheme = () => useContext(ThemeCtx);
+165
View File
@@ -0,0 +1,165 @@
import { createContext, useContext, useEffect, useState, useCallback, useRef } from 'react';
import { api } from '../api.js';
/**
* UI state that persists across sessions:
* - sidebarCollapsed : sidebar visibility
* - fontScale : 'large' (max/accessibilité) | 'medium' | 'compact'
*/
const UiCtx = createContext(null);
const KEY_SIDEBAR = 'cl_ui_sidebar_collapsed';
const KEY_FONT = 'cl_fontscale';
const KEY_LANGUE = 'cl_langue';
const KEY_DEVISE = 'cl_devise';
const KEY_DISPLAY = 'cl_display_mode';
const KEY_CHART_INTERETS = 'cl_chart_interets';
const KEY_CHART_CAPITAL = 'cl_chart_capital';
const KEY_CHART_CASHBACK = 'cl_chart_cashback';
const KEY_PFO_ASSUJETTI = 'cl_pfo_assujetti';
const FONT_SIZES = ['large', 'medium', 'compact'];
const LANGUES = ['fr', 'en'];
const DEVISES = ['EUR', 'USD', 'GBP', 'CHF', 'CAD', 'SGD'];
const DEFAULT_CHART_INTERETS = '#2196f3'; // Blue 500
const DEFAULT_CHART_CAPITAL = '#4caf50'; // Green 500
const DEFAULT_CHART_CASHBACK = '#ffc107'; // Amber 500
function applyFontScale(scale) {
document.documentElement.setAttribute('data-fontsize', scale);
}
export function UiProvider({ children }) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(
() => localStorage.getItem(KEY_SIDEBAR) === '1'
);
const [fontScale, setFontScaleState] = useState(() => {
const saved = localStorage.getItem(KEY_FONT);
return FONT_SIZES.includes(saved) ? saved : 'large';
});
const [langue, setLangueState] = useState(() => {
const saved = localStorage.getItem(KEY_LANGUE);
return LANGUES.includes(saved) ? saved : 'fr';
});
const [devise, setDeviseState] = useState(() => {
const saved = localStorage.getItem(KEY_DEVISE);
return DEVISES.includes(saved) ? saved : 'EUR';
});
const [displayMode, setDisplayModeState] = useState(() => {
const saved = localStorage.getItem(KEY_DISPLAY);
return saved === 'brut' ? 'brut' : 'net';
});
const [chartInterets, setChartInteretsState] = useState(() =>
localStorage.getItem(KEY_CHART_INTERETS) || DEFAULT_CHART_INTERETS
);
const [chartCapital, setChartCapitalState] = useState(() =>
localStorage.getItem(KEY_CHART_CAPITAL) || DEFAULT_CHART_CAPITAL
);
const [chartCashback, setChartCashbackState] = useState(() =>
localStorage.getItem(KEY_CHART_CASHBACK) || DEFAULT_CHART_CASHBACK
);
const [pfoAssujetti, setPfoAssujettiState] = useState(() =>
localStorage.getItem(KEY_PFO_ASSUJETTI) === '1'
);
// ── Sync DB → localStorage au montage ──────────────────────────
// La DB fait foi : ses valeurs écrasent le localStorage si présentes.
useEffect(() => {
api.get('/preferences').then(prefs => {
if (prefs.chart_interets) {
localStorage.setItem(KEY_CHART_INTERETS, prefs.chart_interets);
setChartInteretsState(prefs.chart_interets);
}
if (prefs.chart_capital) {
localStorage.setItem(KEY_CHART_CAPITAL, prefs.chart_capital);
setChartCapitalState(prefs.chart_capital);
}
if (prefs.chart_cashback) {
localStorage.setItem(KEY_CHART_CASHBACK, prefs.chart_cashback);
setChartCashbackState(prefs.chart_cashback);
}
if (prefs.pfo_assujetti !== undefined) {
const val = prefs.pfo_assujetti === '1';
localStorage.setItem(KEY_PFO_ASSUJETTI, val ? '1' : '0');
setPfoAssujettiState(val);
}
}).catch(() => {
// Silencieux : token absent au premier rendu, on garde le localStorage
});
}, []);
// ── Helper : persist une pref en DB (silencieux en cas d'erreur) ─
const persistPref = useCallback((key, value) => {
api.patch('/preferences', { [key]: value }).catch(() => {});
}, []);
useEffect(() => {
localStorage.setItem(KEY_SIDEBAR, sidebarCollapsed ? '1' : '0');
}, [sidebarCollapsed]);
useEffect(() => {
applyFontScale(fontScale);
localStorage.setItem(KEY_FONT, fontScale);
}, [fontScale]);
// Apply on mount
useEffect(() => { applyFontScale(fontScale); }, []);
const toggleSidebar = useCallback(() => setSidebarCollapsed((v) => !v), []);
const setFontScale = useCallback((s) => {
if (FONT_SIZES.includes(s)) setFontScaleState(s);
}, []);
const setLangue = useCallback((l) => {
if (LANGUES.includes(l)) { setLangueState(l); localStorage.setItem(KEY_LANGUE, l); }
}, []);
const setDevise = useCallback((d) => {
if (DEVISES.includes(d)) { setDeviseState(d); localStorage.setItem(KEY_DEVISE, d); }
}, []);
const setDisplayMode = useCallback((m) => {
if (m === 'net' || m === 'brut') {
setDisplayModeState(m);
localStorage.setItem(KEY_DISPLAY, m);
}
}, []);
const setChartInterets = useCallback((hex) => {
setChartInteretsState(hex);
localStorage.setItem(KEY_CHART_INTERETS, hex);
persistPref('chart_interets', hex);
}, [persistPref]);
const setChartCapital = useCallback((hex) => {
setChartCapitalState(hex);
localStorage.setItem(KEY_CHART_CAPITAL, hex);
persistPref('chart_capital', hex);
}, [persistPref]);
const setChartCashback = useCallback((hex) => {
setChartCashbackState(hex);
localStorage.setItem(KEY_CHART_CASHBACK, hex);
persistPref('chart_cashback', hex);
}, [persistPref]);
const setPfoAssujetti = useCallback((val) => {
setPfoAssujettiState(val);
localStorage.setItem(KEY_PFO_ASSUJETTI, val ? '1' : '0');
persistPref('pfo_assujetti', val ? '1' : '0');
}, [persistPref]);
return (
<UiCtx.Provider value={{ sidebarCollapsed, setSidebarCollapsed, toggleSidebar, fontScale, setFontScale, langue, setLangue, devise, setDevise, displayMode, setDisplayMode, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback, pfoAssujetti, setPfoAssujetti }}>
{children}
</UiCtx.Provider>
);
}
export const useUi = () => useContext(UiCtx);
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useMemo, useState } from 'react';
const PAGE_SIZES = [15, 25, 50, 100];
/**
* Hook de pagination côté client.
* @param {Array} items - Liste complète filtrée (exports visent cette liste)
* @param {string} storageKey - Clé localStorage pour la taille de page (ex: 'cl_pagesize_inv')
* @param {Array} resetDeps - Dépendances qui remettent la page à 1 (filtres actifs)
*/
export function usePagination(items, storageKey, resetDeps = []) {
const [pageSize, setPageSizeState] = useState(() => {
const saved = localStorage.getItem(storageKey);
const n = parseInt(saved, 10);
return PAGE_SIZES.includes(n) ? n : 15;
});
const [page, setPage] = useState(1);
// Reset page à 1 dès que les filtres changent
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { setPage(1); }, resetDeps);
const totalItems = items.length;
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
// Clamp si la liste rétrécit sous la page courante
const safePage = Math.min(page, totalPages);
const pagedItems = useMemo(() => {
const start = (safePage - 1) * pageSize;
return items.slice(start, start + pageSize);
}, [items, safePage, pageSize]);
function setPageSize(n) {
setPageSizeState(n);
localStorage.setItem(storageKey, String(n));
setPage(1);
}
return {
pagedItems,
page: safePage,
setPage,
pageSize,
setPageSize,
totalPages,
totalItems,
PAGE_SIZES,
};
}
+25
View File
@@ -0,0 +1,25 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx';
import { AuthProvider } from './context/AuthContext.jsx';
import { InvestisseurProvider } from './context/InvestisseurContext.jsx';
import { ThemeProvider } from './context/ThemeContext.jsx';
import { UiProvider } from './context/UiContext.jsx';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<ThemeProvider>
<UiProvider>
<BrowserRouter>
<AuthProvider>
<InvestisseurProvider>
<App />
</InvestisseurProvider>
</AuthProvider>
</BrowserRouter>
</UiProvider>
</ThemeProvider>
</React.StrictMode>
);
+74
View File
@@ -0,0 +1,74 @@
import { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';
import UsersSection from './admin/UsersSection.jsx';
import CreateUserSection from './admin/CreateUserSection.jsx';
import JobLogsSection from './admin/JobLogsSection.jsx';
import IconsSection from './admin/IconsSection.jsx';
/* ── Icônes nav ───────────────────────────────────────────────── */
function IconUsers() { 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>; }
function IconUserPlus() { 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="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>; }
function IconActivity() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>; }
function IconImage() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>; }
function IconTax() { 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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>; }
function IconDatabase() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>; }
const NAV = [
{
group: 'Administration de la plateforme',
items: [
{ id: 'users', label: 'Utilisateurs', icon: <IconUsers /> },
{ id: 'create', label: 'Créer un utilisateur', icon: <IconUserPlus /> },
{ id: 'job-logs', label: 'Logs des jobs', icon: <IconActivity /> },
{ id: 'icons', label: "Bibliothèque d'icônes", icon: <IconImage /> },
],
},
{
group: 'Référentiels',
items: [
{ id: 'plateformes', label: 'Plateformes', icon: <IconDatabase />, href: '/admin/plateformes' },
{ id: 'fiscalite', label: 'Fiscalité', icon: <IconTax />, href: '/admin/fiscalite' },
],
},
];
export default function Admin() {
const { search } = useLocation();
const navigate = useNavigate();
const [refreshKey, setRefreshKey] = useState(0);
const { user } = useAuth();
const section = new URLSearchParams(search).get('section') || 'users';
if (!user) return null;
return (
<div className="account-layout">
<aside className="account-sidebar">
<h1 className="account-title">Administration</h1>
{NAV.map(group => (
<div key={group.group} className="account-nav-group">
<span className="account-nav-label">{group.group}</span>
{group.items.map(item => (
<button
key={item.id}
className={`account-nav-item${section === item.id ? ' active' : ''}`}
onClick={() => item.href ? navigate(item.href) : navigate(`/admin?section=${item.id}`, { replace: true })}
>
{item.icon}
{item.label}
</button>
))}
</div>
))}
</aside>
<div className="account-content">
{section === 'users' && <UsersSection currentUserId={user?.id} key={refreshKey} />}
{section === 'create' && <CreateUserSection onCreated={() => setRefreshKey(k => k + 1)} />}
{section === 'job-logs' && <JobLogsSection />}
{section === 'icons' && <IconsSection />}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+188
View File
@@ -0,0 +1,188 @@
import { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
/* ── Accordéon FAQ ───────────────────────────────────────────── */
function FaqItem({ question, children }) {
const [open, setOpen] = useState(false);
return (
<div style={{
borderBottom: '1px solid var(--border)',
padding: '0',
}}>
<button
onClick={() => setOpen(o => !o)}
style={{
width: '100%', textAlign: 'left', background: 'none', border: 'none',
padding: '14px 0', cursor: 'pointer', display: 'flex',
alignItems: 'center', justifyContent: 'space-between', gap: 12,
color: 'var(--text)', fontSize: 'var(--fs-base)', fontWeight: 500,
}}
>
<span>{question}</span>
<svg
width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
style={{ flexShrink: 0, transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s' }}
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{open && (
<div style={{
paddingBottom: 16, color: 'var(--text-muted)',
fontSize: 'var(--fs-sm)', lineHeight: 1.7,
}}>
{children}
</div>
)}
</div>
);
}
/* ── Navigation ─────────────────────────────────────────────── */
const NAV = [
{
id: 'faq',
label: 'FAQ',
icon: (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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>
),
},
];
/* ── Page principale ─────────────────────────────────────────── */
export default function Aide() {
const { search } = useLocation();
const navigate = useNavigate();
const section = new URLSearchParams(search).get('section') || 'faq';
const setSection = (s) => navigate(`/aide?section=${s}`, { replace: true });
return (
<div className="account-layout">
{/* ── Nav gauche ───────────────────────────────────────── */}
<aside className="account-sidebar">
<h1 className="account-title">Centre d'aide</h1>
{NAV.map(item => (
<button
key={item.id}
className={`account-nav-item${section === item.id ? ' active' : ''}`}
onClick={() => setSection(item.id)}
>
{item.icon}
{item.label}
</button>
))}
</aside>
{/* ── Contenu ──────────────────────────────────────────── */}
<div className="account-content">
{section === 'faq' && (
<div>
<h2 style={{ marginTop: 0, marginBottom: 24 }}>Questions fréquentes</h2>
<FaqItem question="Comment est calculé le solde du porte-monnaie d'une plateforme ?">
<p style={{ marginTop: 0 }}>
Le solde du porte-monnaie représente les liquidités disponibles sur une plateforme,
c'est-à-dire l'argent que vous pouvez retirer ou réinvestir. Il est calculé comme suit :
</p>
<div style={{ margin: '12px 0', padding: '12px 16px', background: 'var(--surface-2)', borderRadius: 8, fontFamily: 'monospace', fontSize: 'var(--fs-sm)', color: 'var(--text)', lineHeight: 2 }}>
Solde = Dépôts<br />
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; Retraits manuels<br />
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;+ Remboursements crédités au porte-monnaie<br />
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;+ Bonus (parrainage / plateforme)<br />
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; Capital investi (en cours ou remboursé)<br />
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;+ Corrections de solde
</div>
<p><strong style={{ color: 'var(--text)' }}>Retraits manuels</strong> — seuls les retraits que vous avez saisis manuellement sont déduits.
Les retraits générés automatiquement lors d'un remboursement en mode "compte courant" sont exclus,
car ils ne représentent pas un vrai mouvement de porte-monnaie.</p>
<p><strong style={{ color: 'var(--text)' }}>Remboursements crédités au porte-monnaie</strong> — uniquement les remboursements
dont le mode est "Portefeuille" (et non "Compte courant"). Le montant crédité dépend de la fiscalité
de la plateforme :</p>
<ul style={{ margin: '8px 0 8px 16px', paddingLeft: 0 }}>
<li style={{ marginBottom: 6 }}>
<strong style={{ color: 'var(--text)' }}>Plateforme française (Flat Tax)</strong> — le porte-monnaie
reçoit le <em>net reçu</em>, c'est-à-dire le montant après déduction du PFU français (17,2 % de prélèvements
sociaux + 12,8 % d'impôt sur le revenu), prélevé directement à la source par la plateforme.
</li>
<li style={{ marginBottom: 6 }}>
<strong style={{ color: 'var(--text)' }}>Plateforme hors France (sans fiscalité locale)</strong> — le porte-monnaie
reçoit le capital remboursé + cashback + intérêts bruts. Le PFU français n'est pas prélevé à la
source : vous devez le déclarer séparément dans votre déclaration fiscale annuelle.
</li>
<li>
<strong style={{ color: 'var(--text)' }}>Plateforme hors France (avec retenue à la source locale)</strong> — même
principe que ci-dessus, mais la plateforme a déjà prélevé une taxe locale sur les intérêts. Le
porte-monnaie reçoit le capital + cashback + intérêts bruts <em>après</em> cette retenue locale.
Le PFU français reste à déclarer séparément.
</li>
</ul>
<p><strong style={{ color: 'var(--text)' }}>Capital investi</strong> — le montant que vous avez placé dans des prêts actifs
(y compris les réinvestissements complémentaires) est soustrait du porte-monnaie, car ces fonds ne sont
plus disponibles. Ils reviennent progressivement via les remboursements.</p>
<p style={{ marginBottom: 0 }}><strong style={{ color: 'var(--text)' }}>Corrections de solde</strong> — ajustements manuels
permettant de réconcilier de micro-écarts de calcul (par exemple un arrondi de centimes sur la fiscalité).</p>
</FaqItem>
<FaqItem question="Comment mettre en place un réinvestissement automatique des intérêts ?">
<p style={{ marginTop: 0 }}>
Le réinvestissement automatique permet de capitaliser les intérêts perçus après chaque remboursement,
sans aucune saisie manuelle. Les intérêts sont automatiquement réinjectés dans le capital du prêt,
ce qui augmente progressivement le montant investi et les intérêts futurs.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Activation</h4>
<ol style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li>Ouvrez la fiche d'un investissement.</li>
<li>Cliquez sur le bouton <strong style={{ color: 'var(--text)' }}>⋮</strong> en haut à droite du bloc <em>Informations du projet</em>, puis choisissez <strong style={{ color: 'var(--text)' }}>Réinvestir</strong>.</li>
<li>Dans la modale, sélectionnez l'onglet <strong style={{ color: 'var(--text)' }}>Automatique</strong>.</li>
<li>Cliquez sur <strong style={{ color: 'var(--text)' }}>Activer</strong>.</li>
</ol>
<p>
Une fois activé, le bloc <em>Réinvestissements complémentaires</em> apparaît sur la fiche avec
un badge <strong style={{ color: 'var(--primary)' }}>auto</strong>, même si aucun remboursement
n'a encore eu lieu.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Quel montant est réinvesti ?</h4>
<p>Le montant réinvesti après chaque remboursement dépend de la fiscalité de la plateforme :</p>
<ul style={{ margin: '8px 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li>
<strong style={{ color: 'var(--text)' }}>Plateforme française (Flat Tax)</strong> — les <em>intérêts nets</em> sont réinvestis
(après déduction du PFU prélevé à la source). C'est le montant réellement reçu sur votre porte-monnaie.
</li>
<li>
<strong style={{ color: 'var(--text)' }}>Plateforme hors France</strong> les <em>intérêts bruts</em> sont réinvestis,
car aucune retenue n'est effectuée à la source. Pensez à provisionner la fiscalité due lors de votre déclaration annuelle.
</li>
</ul>
<p>Si les intérêts d'un remboursement sont nuls (remboursement de capital seul), aucun réinvestissement n'est créé.</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Désactivation</h4>
<p style={{ marginBottom: 0 }}>
Pour désactiver le réinvestissement automatique, cliquez sur <strong style={{ color: 'var(--text)' }}>⋮</strong> dans le bloc
<em> Informations du projet</em> et choisissez <strong style={{ color: 'var(--text)' }}>Désactiver le réinvestissement auto</strong>.
Les réinvestissements déjà créés sont conservés ; les prochains remboursements n'en génèreront plus.
</p>
</FaqItem>
</div>
)}
</div>
</div>
);
}
+558
View File
@@ -0,0 +1,558 @@
import { useEffect, useState, useRef, useMemo } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import PageIcon from '../components/PageIcon.jsx';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useUi } from '../context/UiContext.jsx';
import { fmtEUR, fmtPct, fmtDate, fmtStatut, memberLabel } from '../utils/format.js';
import InteretsMensuelsChart from '../components/InteretsMensuelsChart.jsx';
import InteretsDonutChart from '../components/InteretsDonutChart.jsx';
import { InteretsChartProvider, useInteretsChart } from '../context/InteretsChartContext.jsx';
import TableauInteretsPlateforme from '../components/TableauInteretsPlateforme.jsx';
import DrillCellPanel from '../components/DrillCellPanel.jsx';
/* ── Sélecteur d'année — doit être enfant de InteretsChartProvider ── */
function YearSelectorKpi() {
const { annee, setAnnee, availableYears, modeGlobal, toggleModeGlobal } = useInteretsChart();
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const handler = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const handleSelect = (value) => {
if (value === 'all') {
if (!modeGlobal) toggleModeGlobal();
} else {
if (modeGlobal) toggleModeGlobal();
setAnnee(value);
}
setOpen(false);
};
// Années récentes en premier (desc), puis "Depuis le début" en tête
const options = [
{ value: 'all', label: 'Depuis le début' },
...availableYears.map(y => ({ value: y, label: String(y) })),
];
return (
<div ref={ref} style={{ position: 'relative', flexShrink: 0, width: 200 }}>
<div
onClick={() => setOpen(v => !v)}
style={{
height: '100%', boxSizing: 'border-box',
background: 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)',
borderRadius: 10,
padding: '16px 20px',
boxShadow: open
? '0 6px 28px rgba(109,40,217,0.45)'
: '0 4px 20px rgba(109,40,217,0.30)',
cursor: 'pointer',
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
userSelect: 'none',
transition: 'box-shadow .15s',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{
fontSize: 'var(--fs-xs)', textTransform: 'uppercase',
letterSpacing: '.06em', color: 'rgba(255,255,255,0.7)', fontWeight: 500,
}}>
Période
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="rgba(255,255,255,0.7)" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<div style={{
color: '#fff',
fontSize: modeGlobal ? '1.1rem' : '2rem',
fontWeight: 700,
lineHeight: 1.1,
marginTop: 8,
}}>
{modeGlobal ? 'Depuis le début' : String(annee)}
</div>
</div>
{open && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 200,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 10, boxShadow: '0 8px 28px rgba(0,0,0,0.15)',
minWidth: 200, overflow: 'hidden',
}}>
{options.map((opt, i) => {
const isActive = opt.value === 'all' ? modeGlobal : (!modeGlobal && annee === opt.value);
return (
<div
key={opt.value}
onClick={() => handleSelect(opt.value)}
style={{
padding: '10px 16px',
cursor: 'pointer',
background: isActive ? 'rgba(109,40,217,0.08)' : 'transparent',
color: isActive ? '#7c3aed' : 'var(--text)',
fontWeight: isActive ? 700 : 400,
fontSize: 'var(--fs-sm)',
borderBottom: i < options.length - 1 ? '1px solid var(--border)' : 'none',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
transition: 'background .1s',
}}
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--surface-2)'; }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
>
<span>{opt.label}</span>
{isActive && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="#7c3aed" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
</div>
);
})}
</div>
)}
</div>
);
}
/* ── Badge de tendance ── */
function TrendBadge({ current, prev, invert = false }) {
if (prev == null || prev === 0) return null;
const diff = current - prev;
const pct = (diff / prev) * 100;
const up = diff > 0;
const neutral = diff === 0;
// invert=true : une hausse est mauvaise (ex. capital en risque)
const good = neutral ? null : (invert ? !up : up);
const color = neutral ? 'var(--text-muted)' : good ? '#16a34a' : '#dc2626';
const bg = neutral ? 'var(--surface-2)' : good ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)';
const arrow = neutral ? '→' : up ? '↗' : '↘';
const label = `${up ? '+' : ''}${Math.abs(pct) < 10 ? pct.toFixed(1) : Math.round(pct)}%`;
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '2px 8px', borderRadius: 20,
background: bg, color, fontSize: '0.76em', fontWeight: 600,
whiteSpace: 'nowrap',
}}>
{arrow} {label}
</span>
);
}
/* ── Carte KPI individuelle ── */
function KpiCard({ title, value, badge, refValue, onClick }) {
return (
<div
className="kpi"
onClick={onClick}
style={onClick ? { cursor: 'pointer', transition: 'box-shadow 0.15s, opacity 0.15s' } : undefined}
onMouseEnter={onClick ? (e) => { e.currentTarget.style.boxShadow = '0 0 0 2px var(--primary)'; e.currentTarget.style.opacity = '0.88'; } : undefined}
onMouseLeave={onClick ? (e) => { e.currentTarget.style.boxShadow = ''; e.currentTarget.style.opacity = ''; } : undefined}
>
<div className="label">{title}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{value}</span>
{badge}
</div>
{refValue && (
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{refValue}</div>
)}
</div>
);
}
/* ── KPI filtrés par année ── */
function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData }) {
const {
annee, modeGlobal, rawDataGlobal, rawData, currentYear, currentMonth,
setInclureInterets, setInclureCapital, setInclureCashback,
} = useInteretsChart();
const isCurrentYear = !modeGlobal && Number(annee) === currentYear;
const isFutureYear = !modeGlobal && Number(annee) > currentYear;
// Capital de référence pour l'année sélectionnée
// Pour les années passées : dernier mois de capitalMensuel (= capital déployé fin décembre)
// Pour l'année courante / mode global : capital actuellement déployé (portfolio)
const capitalAnnee = useMemo(() => {
if (modeGlobal || isCurrentYear) return portfolio.encours + portfolio.en_defaut;
// Année passée : prendre la valeur de fin décembre depuis capitalMensuel
const anneeStr = String(annee);
const moisAnnee = (capitalMensuelData ?? []).filter(c => c.mois?.startsWith(anneeStr));
if (moisAnnee.length > 0) {
// Prendre le dernier mois disponible (normalement décembre)
const last = moisAnnee[moisAnnee.length - 1];
return last.capital ?? 0;
}
return portfolio.encours + portfolio.en_defaut;
}, [modeGlobal, isCurrentYear, annee, capitalMensuelData, portfolio]);
// Capital souscrit par année (pour référence performance N-1, données manquantes, etc.)
const capitalParAnneeMap = useMemo(() => {
const list = rawDataGlobal.capitalParAnnee ?? [];
return Object.fromEntries(list.map(r => [r.annee, r.capital_souscrit]));
}, [rawDataGlobal]);
// ── Estimation réduction PFU pour une année donnée ──
const getPfuReduction = (yr) => {
if (!pfuRates.length) return 0;
const rate = pfuRates.find(r => r.annee === yr)
?? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]);
return (rate.prelev_sociaux + rate.impot_revenu) / 100;
};
// ── Données consolidées pour une année (actuel + projeté selon le cas) ──
const getYearData = (yr) => {
const remRow = (rawDataGlobal.rembourses ?? []).find(r => Number(r.annee) === yr);
const projRow = (rawDataGlobal.projections ?? []).find(r => Number(r.annee) === yr);
if (yr > currentYear) {
// Année future : projections uniquement
const bruts = projRow?.interets_prevus || 0;
const red = getPfuReduction(yr);
return { interets_bruts: bruts, interets_nets: bruts * (1 - red), capital: projRow?.capital_prevu || 0, cashback: 0 };
}
if (yr === currentYear) {
// Année en cours : actuel reçu + reste projeté
const bruts_act = remRow?.interets_bruts || 0;
const bruts_proj = projRow?.interets_prevus || 0;
const red = getPfuReduction(yr);
return {
interets_bruts: bruts_act + bruts_proj,
interets_nets: (remRow?.interets_nets || 0) + bruts_proj * (1 - red),
capital: (remRow?.capital || 0) + (projRow?.capital_prevu || 0),
cashback: remRow?.cashback || 0,
};
}
// Année passée : actuel uniquement
return {
interets_bruts: remRow?.interets_bruts || 0,
interets_nets: remRow?.interets_nets || 0,
capital: remRow?.capital || 0,
cashback: remRow?.cashback || 0,
};
};
// ── Données mensuelles (mois en cours + M-1) — uniquement si année courante ──
const { thisMonthRow, prevMonthRow, prevMonthLabel, capitalCurrent, capitalPrev, enDefautCurrent, enDefautPrev } = useMemo(() => {
const MOIS_FR = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.'];
const rows = rawData.rembourses ?? [];
const thisStr = String(currentYear) + '-' + String(currentMonth).padStart(2, '0');
const curr = rows.find(r => r.mois === thisStr) ?? null;
const prevDate = new Date(currentYear, currentMonth - 2, 1);
const prevStr = String(prevDate.getFullYear()) + '-' + String(prevDate.getMonth() + 1).padStart(2, '0');
const prev = rows.find(r => r.mois === prevStr) ?? null;
const label = MOIS_FR[prevDate.getMonth()] + ' ' + prevDate.getFullYear();
// Capital investi et en risque M vs M-1 depuis capitalMensuelData
const capRows = capitalMensuelData ?? [];
const capCurr = capRows.find(c => c.mois === thisStr);
const capPrev = capRows.find(c => c.mois === prevStr);
return {
thisMonthRow: curr, prevMonthRow: prev, prevMonthLabel: label,
capitalCurrent: capCurr?.capital ?? null,
capitalPrev: capPrev?.capital ?? null,
enDefautCurrent: capCurr?.en_defaut ?? null,
enDefautPrev: capPrev?.en_defaut ?? null,
};
}, [rawData, currentYear, currentMonth, capitalMensuelData]);
// ── Données annuelles (année sélectionnée + N-1) ──
const { annualData, prevAnnualData } = useMemo(() => {
if (modeGlobal) {
const total = (rawDataGlobal.rembourses ?? []).reduce((acc, r) => ({
interets_bruts: acc.interets_bruts + (r.interets_bruts || 0),
interets_nets: acc.interets_nets + (r.interets_nets || 0),
capital: acc.capital + (r.capital || 0),
cashback: acc.cashback + (r.cashback || 0),
}), { interets_bruts: 0, interets_nets: 0, capital: 0, cashback: 0 });
return { annualData: total, prevAnnualData: null };
}
return {
annualData: getYearData(Number(annee)),
prevAnnualData: getYearData(Number(annee) - 1),
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rawDataGlobal, annee, modeGlobal, pfuRates]);
// ── Valeur affichée ──
const getValue = (field) => {
if (isCurrentYear) return thisMonthRow?.[field] || 0;
return annualData[field] || 0;
};
// ── Badge et référence ──
const mkBadge = (field) => {
if (modeGlobal) return null;
if (isCurrentYear) {
if (!thisMonthRow || !prevMonthRow) return null;
return <TrendBadge current={thisMonthRow[field] || 0} prev={prevMonthRow[field] || 0} />;
}
if (!prevAnnualData) return null;
return <TrendBadge current={annualData[field] || 0} prev={prevAnnualData[field] || 0} />;
};
const mkRef = (field) => {
if (modeGlobal) return null;
if (isCurrentYear) {
if (!prevMonthRow) return null;
return fmtEUR(prevMonthRow[field] || 0) + ' en ' + prevMonthLabel;
}
if (!prevAnnualData) return null;
const prevYr = Number(annee) - 1;
const suffix = prevYr >= currentYear ? ' (proj.)' : '';
return fmtEUR(prevAnnualData[field] || 0) + ' en ' + prevYr + suffix;
};
const interetsField = netMode ? 'interets_nets' : 'interets_bruts';
// ── Performance annualisée ──────────────────────────────────────
const calcPerfRatio = (interetsVal, capital) =>
capital > 0 && interetsVal != null ? interetsVal / capital : null;
// Mensuelle annualisée (mode année courante)
const capitalDeploye = portfolio.encours + portfolio.en_defaut;
const perfCurrent = isCurrentYear && thisMonthRow && capitalDeploye > 0
? calcPerfRatio(thisMonthRow[interetsField] || 0, capitalDeploye) * 12
: null;
const perfPrev = isCurrentYear && prevMonthRow && capitalDeploye > 0
? calcPerfRatio(prevMonthRow[interetsField] || 0, capitalDeploye) * 12
: null;
// Annuelle (mode année passée/future) — même formule que le tableau :
// (interets + cashback) / capital souscrit cette année-là
const perfInteretsAnnee = (annualData[interetsField] || 0) + (annualData.cashback || 0);
const perfAnnual = !modeGlobal && !isCurrentYear && capitalAnnee > 0
? calcPerfRatio(perfInteretsAnnee, capitalAnnee)
: null;
const prevCapitalAnnee = modeGlobal || isCurrentYear
? capitalDeploye
: (capitalParAnneeMap[Number(annee) - 1] ?? capitalDeploye);
const perfInteretsPrevAnnee = ((prevAnnualData?.[interetsField] || 0) + (prevAnnualData?.cashback || 0));
const perfAnnualPrev = !modeGlobal && !isCurrentYear && prevAnnualData && prevCapitalAnnee > 0
? calcPerfRatio(perfInteretsPrevAnnee, prevCapitalAnnee)
: null;
const perfValue = modeGlobal ? null : (isCurrentYear ? perfCurrent : perfAnnual);
const perfPrevVal = modeGlobal ? null : (isCurrentYear ? perfPrev : perfAnnualPrev);
const perfLabel = netMode ? 'Performance nette annualisée' : 'Performance brute annualisée';
const perfRefLabel = isCurrentYear
? (perfPrevVal != null ? fmtPct(perfPrevVal * 100) + ' en ' + prevMonthLabel : null)
: (perfAnnualPrev != null ? fmtPct(perfAnnualPrev * 100) + ' en ' + (Number(annee) - 1) : null);
return (
<div className="kpi-grid" style={{ flex: 1, marginBottom: 0 }}>
<KpiCard
title="Capital investi"
value={fmtEUR(capitalAnnee)}
badge={isCurrentYear && capitalCurrent != null && capitalPrev != null && capitalPrev > 0
? <TrendBadge current={capitalCurrent} prev={capitalPrev} />
: null}
refValue={isCurrentYear && capitalPrev != null
? fmtEUR(capitalPrev) + ' en ' + prevMonthLabel
: null}
/>
<KpiCard
title="Capital en risque"
value={fmtEUR(portfolio.en_defaut)}
badge={isCurrentYear && enDefautCurrent != null && enDefautPrev != null && enDefautPrev > 0
? <TrendBadge current={enDefautCurrent} prev={enDefautPrev} invert={true} />
: null}
refValue={isCurrentYear && enDefautPrev != null && enDefautPrev > 0
? fmtEUR(enDefautPrev) + ' en ' + prevMonthLabel
: null}
/>
<KpiCard
title={perfLabel + (isFutureYear ? ' (proj.)' : '')}
value={perfValue != null ? fmtPct(perfValue * 100) : '—'}
badge={perfValue != null && perfPrevVal != null
? <TrendBadge current={perfValue} prev={perfPrevVal} />
: null}
refValue={perfRefLabel}
/>
<KpiCard
title={(netMode ? 'Intérêts nets' : 'Intérêts bruts') + (isFutureYear ? ' (proj.)' : '')}
value={fmtEUR(getValue(interetsField))}
badge={mkBadge(interetsField)}
refValue={mkRef(interetsField)}
onClick={() => { setInclureInterets(true); setInclureCapital(false); setInclureCashback(false); }}
/>
<KpiCard
title={'Capital remboursé' + (isFutureYear ? ' (proj.)' : '')}
value={fmtEUR(getValue('capital'))}
badge={mkBadge('capital')}
refValue={mkRef('capital')}
onClick={() => { setInclureInterets(false); setInclureCapital(true); setInclureCashback(false); }}
/>
<KpiCard
title="Cashback reçu"
value={fmtEUR(getValue('cashback'))}
badge={mkBadge('cashback')}
refValue={mkRef('cashback')}
onClick={() => { setInclureInterets(false); setInclureCapital(false); setInclureCashback(true); }}
/>
</div>
);
}
export default function Dashboard() {
const { activeId, activeView, activeViewMember, investisseurs } = useInvestisseur();
const { displayMode } = useUi();
const netMode = displayMode === 'net';
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [data, setData] = useState(null);
const [pfuRates, setPfuRates] = useState([]);
const [loading, setLoading] = useState(false);
const [capitalMensuelData, setCapitalMensuelData] = useState([]);
const [plateformes, setPlateformes] = useState([]);
/* ── drillCell : cellule sélectionnée dans le TIP — par défaut mois courant toutes plateformes ── */
const _now = new Date();
const _moisLabels = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
/* Restaure le drillCell depuis les params URL (retour depuis Remboursements) */
const _initDrillCell = () => {
const ba = searchParams.get('drill-annee');
const bm = searchParams.get('drill-mois');
const bp = searchParams.get('drill-plat');
if (ba && bm) {
const annee = Number(ba), mois = Number(bm);
return {
platId: bp ? Number(bp) : null,
platNom: null,
annee,
mois,
moisLabel: _moisLabels[mois - 1],
};
}
return {
platId: null,
platNom: null,
annee: _now.getFullYear(),
mois: _now.getMonth() + 1,
moisLabel: _moisLabels[_now.getMonth()],
};
};
const [drillCell, setDrillCell] = useState(_initDrillCell);
/* Nettoie les params URL de retour dès le premier rendu */
useEffect(() => {
if (searchParams.get('drill-annee')) {
setSearchParams({}, { replace: true });
}
}, []); /* eslint-disable-next-line */
useEffect(() => {
api.get('/pfu').then(setPfuRates).catch(() => {});
api.get('/plateformes').then(setPlateformes).catch(() => {});
}, []);
useEffect(() => {
if (!activeId && activeView !== 'all') return;
setData(null);
setLoading(true);
const params = activeView === 'all' ? { scope: 'all' } : undefined;
api.get('/dashboard', params).then(setData).finally(() => setLoading(false));
}, [activeView, activeId]);
const ready = activeView === 'all' ? investisseurs.length > 0 : !!activeId;
if (!ready) return <div className="card text-muted">Sélectionnez un compte investisseur.</div>;
if (loading || !data) return <div className="card text-muted">Chargement</div>;
const { cash, cashByPlatform, portfolio, interets, interetsParAnnee } = data;
const viewTitle = activeView === 'all'
? 'Famille et entreprises'
: memberLabel(activeViewMember);
return (
<>
<div className="topbar"><h2><PageIcon name="dashboard" />Tableau de bord {viewTitle}</h2></div>
<InteretsChartProvider netMode={netMode} pfuRates={pfuRates} activeView={activeView} activeId={activeId}>
{/* ── KPI + sélecteur année ── */}
<div style={{ display: 'flex', gap: 12, alignItems: 'stretch', marginBottom: 16 }}>
<DashboardKpis portfolio={portfolio} netMode={netMode} pfuRates={pfuRates} capitalMensuelData={capitalMensuelData} />
<YearSelectorKpi />
</div>
{/* ── Graphiques ── */}
<div style={{ display: 'flex', gap: 16, alignItems: 'stretch', marginTop: 8, marginBottom: 24 }}>
<div style={{ flex: 2, minWidth: 0 }}>
<InteretsMensuelsChart />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<InteretsDonutChart />
</div>
</div>
{/* ── Tableau intérêts par plateforme ── */}
<div style={{ marginBottom: 0 }}>
<TableauInteretsPlateforme
activeView={activeView}
activeId={activeId}
pfuRates={pfuRates}
onCapitalMensuel={setCapitalMensuelData}
onCellClick={({ platId, platNom, annee, mois, moisLabel }) =>
setDrillCell({ platId, platNom, annee, mois, moisLabel })
}
activeCell={drillCell}
/>
</div>
{/* ── Panneau détail mois / plateforme (remplace les Échéances prévues) ── */}
<DrillCellPanel
cell={drillCell}
alwaysOpen={true}
pfuRates={pfuRates}
activeView={activeView}
activeId={activeId}
plateformes={plateformes}
investissements={[]}
onBulkDone={() => {}}
onEditRecu={(r) => {
const q = new URLSearchParams({
'edit-remb': r.id,
from: 'dashboard',
'drill-annee': drillCell.annee,
'drill-mois': drillCell.mois,
...(drillCell.platId ? { 'drill-plat': drillCell.platId } : {}),
});
navigate(`/remboursements?${q}`);
}}
onEditProjet={(p) => {
const q = new URLSearchParams({
'open-simul': p.investissement_id,
'simul-date': p.date_prevue,
'simul-capital': p.capital_prevu ?? 0,
'simul-interets': p.interets_prevus ?? 0,
from: 'dashboard',
'drill-annee': drillCell.annee,
'drill-mois': drillCell.mois,
...(drillCell.platId ? { 'drill-plat': drillCell.platId } : {}),
});
navigate(`/remboursements?${q}`);
}}
/>
</InteretsChartProvider>
</>
);
}
File diff suppressed because it is too large Load Diff
+330
View File
@@ -0,0 +1,330 @@
import { useEffect, useRef, useState } from 'react';
import { api } from '../api.js';
import { memberInitials, memberDisplayName } from '../utils/format.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import Modal from '../components/Modal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx';
/* ── Avatar ─────────────────────────────────────────────────── */
function MemberAvatar({ membre, size = 40 }) {
const initials = memberInitials(membre);
const bg = membre.type === 'entreprise'
? 'linear-gradient(135deg, #4f46e5 0%, #3730a3 100%)'
: 'linear-gradient(135deg, #1e40af 0%, #1e3a8a 100%)';
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: bg,
color: 'white',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 700, fontSize: Math.round(size * 0.35),
flexShrink: 0, letterSpacing: '.03em', userSelect: 'none',
}}>
{initials}
</div>
);
}
/* ── Menu "···" par membre ──────────────────────────────────── */
function MemberMenu({ onEdit, onDelete, isPrincipal, isOnly }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const h = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
return (
<div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
<button className="member-dots-btn" onClick={() => setOpen(o => !o)}
aria-label="Actions" aria-haspopup="menu">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/>
</svg>
</button>
{open && (
<div className="member-dots-menu" role="menu">
<button className="member-dots-menu-item" role="menuitem" onClick={() => { setOpen(false); onEdit(); }}>
Modifier
</button>
{!isPrincipal && (
<button className="member-dots-menu-item danger-item" role="menuitem"
disabled={isOnly}
title={isOnly ? 'Impossible de supprimer le dernier profil' : ''}
onClick={() => { setOpen(false); onDelete(); }}>
Supprimer
</button>
)}
</div>
)}
</div>
);
}
/* ── Ligne "Ajouter" ─────────────────────────────────────────── */
function AddRow({ label, onClick }) {
return (
<div className="member-add-row" onClick={onClick} role="button" tabIndex={0}
onKeyDown={e => e.key === 'Enter' && onClick()}>
<div className="member-add-circle">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</div>
<span>{label}</span>
</div>
);
}
/* ── Composant principal ─────────────────────────────────────── */
export default function FamilleEntreprises() {
const { reload: reloadCtx } = useInvestisseur();
const [membres, setMembres] = useState([]);
const [tab, setTab] = useState('famille');
const [err, setErr] = useState(null);
/* Modals */
const [modalFamille, setModalFamille] = useState(false);
const [modalEntreprise, setModalEntreprise] = useState(false);
const [editTarget, setEditTarget] = useState(null); // membre à éditer
/* Formulaires */
const emptyFam = { prenom: '', nom_famille: '' };
const emptyEnt = { nom: '', type_fiscal: 'PM' };
const [famForm, setFamForm] = useState(emptyFam);
const [entForm, setEntForm] = useState(emptyEnt);
const [saving, setSaving] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(null);
const load = async () => {
const list = await api.get('/investisseurs');
setMembres(list);
};
useEffect(() => { load(); }, []);
const famille = membres.filter(m => m.type === 'famille');
const entreprises = membres.filter(m => m.type === 'entreprise');
const totalCount = membres.length;
/* ── Ouvrir modal édition ─────────────────────────────────── */
const openEdit = (m) => {
setEditTarget(m);
if (m.type === 'famille') {
const restNom = m.prenom ? m.nom.replace(m.prenom, '').trim() : m.nom;
setFamForm({ prenom: m.prenom || '', nom_famille: restNom });
setModalFamille(true);
} else {
setEntForm({ nom: m.nom, type_fiscal: m.type_fiscal || 'PM' });
setModalEntreprise(true);
}
};
const closeModals = () => {
setModalFamille(false); setModalEntreprise(false);
setEditTarget(null);
setFamForm(emptyFam); setEntForm(emptyEnt);
setErr(null);
};
/* ── Sauvegarde famille ────────────────────────────────────── */
const saveFamille = async (e) => {
e.preventDefault(); setErr(null); setSaving(true);
try {
const fullName = [famForm.prenom.trim(), famForm.nom_famille.trim()].filter(Boolean).join(' ');
if (!fullName) throw new Error('Veuillez renseigner au moins un prénom ou un nom.');
const payload = {
nom: fullName,
prenom: famForm.prenom.trim() || null,
type: 'famille',
type_fiscal: 'PP',
};
if (editTarget) {
await api.put(`/investisseurs/${editTarget.id}`, payload);
} else {
await api.post('/investisseurs', payload);
}
await load(); await reloadCtx();
closeModals();
} catch (e) { setErr(e.message); }
finally { setSaving(false); }
};
/* ── Sauvegarde entreprise ─────────────────────────────────── */
const saveEntreprise = async (e) => {
e.preventDefault(); setErr(null); setSaving(true);
try {
const payload = {
nom: entForm.nom.trim(),
prenom: null,
type: 'entreprise',
type_fiscal: entForm.type_fiscal,
};
if (editTarget) {
await api.put(`/investisseurs/${editTarget.id}`, payload);
} else {
await api.post('/investisseurs', payload);
}
await load(); await reloadCtx();
closeModals();
} catch (e) { setErr(e.message); }
finally { setSaving(false); }
};
/* ── Suppression ──────────────────────────────────────────── */
const deleteMembre = (m) => {
setDeleteConfirm({
message: `Supprimer "${memberDisplayName(m)}" ? Tous les investissements associés seront effacés.`,
onConfirm: async () => {
try {
await api.del(`/investisseurs/${m.id}`);
await load(); await reloadCtx();
} catch (e) { setErr(e.message); }
finally { setDeleteConfirm(null); }
},
});
};
/* ── Render ───────────────────────────────────────────────── */
const currentList = tab === 'famille' ? famille : entreprises;
return (
<div className="famille-wrap">
{/* Tabs */}
<div className="famille-tabs">
<button
className={`famille-tab${tab === 'famille' ? ' active' : ''}`}
onClick={() => setTab('famille')}
>
Famille
<span className="famille-tab-count">{famille.length}</span>
</button>
<button
className={`famille-tab${tab === 'entreprise' ? ' active' : ''}`}
onClick={() => setTab('entreprise')}
>
Entreprises
<span className="famille-tab-count">{entreprises.length}</span>
</button>
</div>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
{/* Liste */}
<div className="membre-list">
{currentList.map(m => (
<div key={m.id} className="membre-row">
<MemberAvatar membre={m} size={42} />
<div className="membre-info">
<span className="membre-name">{memberDisplayName(m)}</span>
{m.type === 'famille' && (
<span className="membre-role">
{m.is_principal ? '(compte principal)' : '(Membre de la famille)'}
</span>
)}
</div>
{m.type_fiscal && m.type === 'entreprise' && (
<span className="membre-badge">{m.type_fiscal}</span>
)}
<MemberMenu
onEdit={() => openEdit(m)}
onDelete={() => deleteMembre(m)}
isPrincipal={!!m.is_principal}
isOnly={totalCount <= 1}
/>
</div>
))}
{/* Ligne d'ajout */}
{tab === 'famille' && (
<AddRow label="Ajouter une personne"
onClick={() => { setEditTarget(null); setFamForm(emptyFam); setModalFamille(true); }} />
)}
{tab === 'entreprise' && (
<AddRow label="Ajouter une entreprise"
onClick={() => { setEditTarget(null); setEntForm(emptyEnt); setModalEntreprise(true); }} />
)}
</div>
{/* ── Modal famille ──────────────────────────────────────── */}
<Modal
open={modalFamille}
title={editTarget ? 'Modifier le membre' : 'Ajouter une personne'}
onClose={closeModals}
footer={
<>
<button className="ghost" type="button" onClick={closeModals}>Annuler</button>
<button className="primary" form="form-famille" type="submit" disabled={saving}>
{saving ? '…' : 'Enregistrer'}
</button>
</>
}
>
<form id="form-famille" onSubmit={saveFamille}
style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{err && <div className="error">{err}</div>}
<div className="modal-field">
<label>Prénom</label>
<input autoFocus value={famForm.prenom}
onChange={e => setFamForm({ ...famForm, prenom: e.target.value })}
placeholder="Olivier" />
</div>
<div className="modal-field">
<label>Nom de famille <span className="text-muted" style={{ fontWeight: 400 }}>(optionnel)</span></label>
<input value={famForm.nom_famille}
onChange={e => setFamForm({ ...famForm, nom_famille: e.target.value })}
placeholder="CROGUENNEC" />
</div>
</form>
</Modal>
{/* ── Modal entreprise ───────────────────────────────────── */}
<Modal
open={modalEntreprise}
title={editTarget ? "Modifier l'entreprise" : 'Ajouter une entreprise'}
onClose={closeModals}
footer={
<>
<button className="ghost" type="button" onClick={closeModals}>Annuler</button>
<button className="primary" form="form-entreprise" type="submit" disabled={saving}>
{saving ? '…' : 'Enregistrer'}
</button>
</>
}
>
<form id="form-entreprise" onSubmit={saveEntreprise}
style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{err && <div className="error">{err}</div>}
<div className="modal-field">
<label>Nom de l'entreprise *</label>
<input autoFocus required value={entForm.nom}
onChange={e => setEntForm({ ...entForm, nom: e.target.value })}
placeholder="SCI Famille Croguennec" />
</div>
<div className="modal-field">
<label>Forme juridique</label>
<select value={entForm.type_fiscal}
onChange={e => setEntForm({ ...entForm, type_fiscal: e.target.value })}>
<option value="PM">Personne morale</option>
<option value="SCI">SCI</option>
<option value="SCPI">SCPI</option>
<option value="SARL">SARL</option>
<option value="SAS">SAS</option>
<option value="SA">SA</option>
</select>
</div>
</form>
</Modal>
<ConfirmModal
open={!!deleteConfirm}
message={deleteConfirm?.message}
onConfirm={deleteConfirm?.onConfirm}
onCancel={() => setDeleteConfirm(null)}
/>
</div>
);
}
+455
View File
@@ -0,0 +1,455 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { fmtDate } from '../utils/format.js';
import ResultBanner from '../components/ResultBanner.jsx';
const MODULES = {
depots_retraits: {
label: 'Dépôts / Retraits',
required: ['date_operation', 'type', 'montant'],
optional: ['plateforme_id', 'libelle', 'reference'],
needsInvestisseur: true,
},
investissements: {
label: 'Investissements',
required: ['nom_projet', 'date_souscription', 'montant_investi'],
optional: ['plateforme_id', 'emetteur', 'date_premiere_echeance', 'date_cible', 'taux_interet', 'duree_mois', 'type_remb', 'freq_interets', 'statut', 'reference'],
needsInvestisseur: true,
},
remboursements: {
label: 'Remboursements',
required: ['investissement_id', 'date_remb'],
optional: ['capital', 'interets_bruts', 'prelev_sociaux', 'prelev_forfaitaire', 'net_recu', 'statut'],
needsInvestisseur: true,
},
plateformes: {
label: 'Plateformes',
required: ['nom'],
optional: ['url', 'notes'],
needsInvestisseur: false,
note: 'Les plateformes dont le nom existe déjà seront ignorées (pas d\'écrasement).',
},
taux_pfu: {
label: 'Flat Tax — Taux PFU',
required: ['annee', 'pfu_total', 'impot_revenu', 'prelev_sociaux'],
optional: [],
needsInvestisseur: false,
global: true,
note: 'Table de référence globale. Si une année existe déjà, ses taux seront mis à jour (upsert).',
},
};
const MODULE_LABEL = {
depots_retraits: 'Dépôts / Retraits',
investissements: 'Investissements',
remboursements: 'Remboursements',
plateformes: 'Plateformes',
taux_pfu: 'Flat Tax — Taux PFU',
};
export default function Imports() {
const { activeId } = useInvestisseur();
const navigate = useNavigate();
// ── Import classique (xlsx/csv/json) ──────────────────────────
const [module, setModule] = useState('depots_retraits');
const [file, setFile] = useState(null);
const [preview, setPreview] = useState(null);
const [mapping, setMapping] = useState({});
const [defaults, setDefaults] = useState({});
const [plats, setPlats] = useState([]);
const [investissements,setInvestissements] = useState([]);
const [history, setHistory] = useState([]);
const [result, setResult] = useState(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState(null);
// ── Import dossier investissement ─────────────────────────────
const [dossierFile, setDossierFile] = useState(null);
const [dossierPreview, setDossierPreview] = useState(null); // parsed JSON for review
const [dossierResult, setDossierResult] = useState(null);
const [dossierBusy, setDossierBusy] = useState(false);
const [dossierErr, setDossierErr] = useState(null);
const dossierInputRef = useRef(null);
// History + plateformes sont user-scoped → chargement sans activeId
useEffect(() => {
api.get('/imports/history').then(setHistory).catch(() => {});
api.get('/plateformes').then(setPlats).catch(() => {});
}, []);
// Investissements sont investisseur-scoped → besoin de activeId
useEffect(() => {
if (!activeId) return;
api.get('/investissements').then(setInvestissements).catch(() => {});
}, [activeId]);
const def = MODULES[module];
const allTargets = def ? [...def.required, ...def.optional] : [];
const missingInv = def?.needsInvestisseur && !activeId;
const onPreview = async () => {
if (!file) return;
setBusy(true); setErr(null); setResult(null);
try {
const fd = new FormData();
fd.append('file', file);
const r = await api.upload('/imports/preview', fd);
setPreview(r);
// Auto-map colonnes dont le nom correspond à une cible
const auto = {};
for (const t of allTargets) {
const col = r.headers.find(h => h.toLowerCase().replace(/\W/g, '_') === t);
if (col) auto[t] = col;
}
setMapping(auto);
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
};
const apply = async () => {
setBusy(true); setErr(null);
try {
const r = await api.post('/imports/apply', {
tempId: preview.tempId, module, mapping, defaults,
originalFilename: file?.name ?? preview.filename,
});
setResult({
ok: true,
msg: `✔ Import terminé : ${r.inserted} / ${r.total} lignes insérées${r.skipped > 0 ? `, ${r.skipped} ignorées` : ''}.${r.errors?.length > 0 ? ` (${r.errors.length} avertissement(s))` : ''}`,
});
setPreview(null); setFile(null); setMapping({}); setDefaults({});
api.get('/imports/history').then(setHistory).catch(() => {});
// Recharger les plateformes si c'est ce qui vient d'être importé
if (module === 'plateformes') {
api.get('/plateformes').then(setPlats).catch(() => {});
}
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
};
return (
<>
<div className="topbar"><h2>Import Données</h2></div>
<div className="card">
<h3 style={{ marginTop: 0 }}>1. Fichier source</h3>
<div className="row">
<div>
<label>Module cible</label>
<select value={module} onChange={e => {
setModule(e.target.value);
setPreview(null); setMapping({}); setResult(null); setErr(null);
}}>
{Object.entries(MODULES).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
</div>
<div style={{ flex: 2 }}>
<label>Fichier .xlsx, .csv ou .json</label>
<input type="file" accept=".xlsx,.xls,.csv,.json" onChange={e => {
setFile(e.target.files[0]);
setPreview(null); setResult(null); setErr(null);
}} />
</div>
<div>
<button className="primary" onClick={onPreview}
disabled={!file || busy || missingInv}>
{busy ? '…' : 'Analyser'}
</button>
</div>
</div>
{/* Note contextuelle du module sélectionné */}
{def?.note && (
<div className="import-module-note">
{def.global && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ flexShrink: 0, marginTop: 1, color: 'var(--warning)' }}>
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
)}
{def.note}
</div>
)}
{/* Avertissement si module investisseur-scoped mais aucun investisseur actif */}
{missingInv && (
<div className="error" style={{ marginTop: 10 }}>
Sélectionnez un investisseur actif avant d'importer ce module.
</div>
)}
{err && <div className="error" style={{ marginTop: 12 }}>{err}</div>}
<ResultBanner result={result} onDismiss={() => setResult(null)} style={{ marginTop: 12 }} />
</div>
{preview && (
<>
<div className="card">
<h3 style={{ marginTop: 0 }}>2. Mappage des colonnes</h3>
<p className="text-muted" style={{ fontSize: 12 }}>
Fichier : <strong>{preview.filename}</strong> — feuille <em>{preview.sheetName}</em> — {preview.allRowCount} lignes.
{' '}Champs marqués <span style={{ color: 'var(--danger)' }}>*</span> obligatoires.
{' '}Si la colonne n'existe pas, fournissez une valeur par défaut.
</p>
<table>
<thead>
<tr>
<th>Champ cible</th>
<th>Colonne Excel</th>
<th>Valeur par défaut</th>
</tr>
</thead>
<tbody>
{allTargets.map(t => {
const isReq = def.required.includes(t);
return (
<tr key={t}>
<td>
<code style={{ fontSize: 11 }}>{t}</code>
{isReq && <span style={{ color: 'var(--danger)' }}> *</span>}
</td>
<td>
<select value={mapping[t] || ''}
onChange={e => setMapping({ ...mapping, [t]: e.target.value })}>
<option value=""> ignorer </option>
{preview.headers.map(h => <option key={h} value={h}>{h}</option>)}
</select>
</td>
<td>
{t === 'plateforme_id' ? (
<select value={defaults[t] || ''}
onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}>
<option value=""></option>
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? `${p.investisseur_nom}` : ''}</option>)}
</select>
) : t === 'investissement_id' ? (
<select value={defaults[t] || ''}
onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}>
<option value=""></option>
{investissements.map(i => <option key={i.id} value={i.id}>{i.nom_projet}</option>)}
</select>
) : (
<input value={defaults[t] || ''}
onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}
placeholder={t === 'statut' ? 'ex. en_cours' : t === 'type' ? 'ex. depot' : ''} />
)}
</td>
</tr>
);
})}
</tbody>
</table>
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => { setPreview(null); setMapping({}); }}>Annuler</button>
<button className="primary" onClick={apply} disabled={busy || missingInv}>
{busy ? '…' : `Importer ${preview.allRowCount} lignes`}
</button>
</div>
</div>
<div className="card">
<h3 style={{ marginTop: 0 }}>Aperçu (10 premières lignes)</h3>
<div style={{ overflowX: 'auto' }}>
<table>
<thead>
<tr>{preview.headers.map(h => <th key={h}>{h}</th>)}</tr>
</thead>
<tbody>
{preview.sampleRows.map((r, i) => (
<tr key={i}>{preview.headers.map(h => <td key={h}>{String(r[h] ?? '')}</td>)}</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
{/* ── Import Dossier Investissement ──────────────────────── */}
<DossierImport
activeId={activeId}
navigate={navigate}
dossierFile={dossierFile}
setDossierFile={setDossierFile}
dossierPreview={dossierPreview}
setDossierPreview={setDossierPreview}
dossierResult={dossierResult}
setDossierResult={setDossierResult}
dossierBusy={dossierBusy}
setDossierBusy={setDossierBusy}
dossierErr={dossierErr}
setDossierErr={setDossierErr}
dossierInputRef={dossierInputRef}
reloadHistory={() => api.get('/imports/history').then(setHistory).catch(() => {})}
/>
<div className="card">
<h3 style={{ marginTop: 0 }}>Historique des imports</h3>
<table>
<thead>
<tr>
<th>Date</th>
<th>Module</th>
<th>Fichier</th>
<th className="num">Total</th>
<th className="num">OK</th>
<th className="num">KO</th>
</tr>
</thead>
<tbody>
{history.length === 0 && (
<tr><td colSpan={6} className="text-muted" style={{ textAlign: 'center' }}>Aucun import</td></tr>
)}
{history.map(h => (
<tr key={h.id}>
<td>{fmtDate(h.created_at)}</td>
<td>{MODULE_LABEL[h.module] ?? h.module}</td>
<td className="text-muted" style={{ fontSize: 11 }}>{h.filename}</td>
<td className="num">{h.rows_total}</td>
<td className="num" style={{ color: 'var(--success)' }}>{h.rows_inserted}</td>
<td className="num" style={{ color: h.rows_skipped > 0 ? 'var(--warning)' : undefined }}>
{h.rows_skipped}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}
/* ── Composant import dossier ──────────────────────────────────── */
function DossierImport({
activeId, navigate,
dossierFile, setDossierFile,
dossierPreview, setDossierPreview,
dossierResult, setDossierResult,
dossierBusy, setDossierBusy,
dossierErr, setDossierErr,
dossierInputRef, reloadHistory,
}) {
const missingInv = !activeId;
const onFileChange = (e) => {
const f = e.target.files[0];
setDossierFile(f || null);
setDossierPreview(null);
setDossierResult(null);
setDossierErr(null);
if (!f) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const parsed = JSON.parse(ev.target.result);
if (parsed.type !== 'dossier_investissement') {
setDossierErr('Ce fichier n\'est pas un dossier investissement valide (type incorrect).');
return;
}
setDossierPreview(parsed);
} catch {
setDossierErr('Fichier JSON invalide — vérifiez la syntaxe.');
}
};
reader.readAsText(f);
};
const onImport = async () => {
if (!dossierPreview) return;
setDossierBusy(true); setDossierErr(null); setDossierResult(null);
try {
const r = await api.post('/imports/dossier', { dossier: dossierPreview });
setDossierResult(r);
setDossierFile(null); setDossierPreview(null);
if (dossierInputRef.current) dossierInputRef.current.value = '';
reloadHistory();
} catch (e) { setDossierErr(e.message); }
finally { setDossierBusy(false); }
};
const dp = dossierPreview;
const inv = dp?.investissement;
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
return (
<div className="card">
<h3 style={{ marginTop: 0 }}>Import Dossier investissement</h3>
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', marginBottom: 12 }}>
Restaure ou migre un dossier complet (investissement + remboursements + historique) depuis un fichier
<code style={{ margin: '0 4px' }}>.json</code> exporté par cette application.
Si le dossier existe déjà, il sera mis à jour ; sinon il sera créé.
</p>
{missingInv && (
<div className="error" style={{ marginBottom: 10 }}>
Sélectionnez un investisseur actif avant d'importer un dossier.
</div>
)}
<div className="row" style={{ gap: 10, alignItems: 'flex-end' }}>
<div style={{ flex: 1 }}>
<label>Fichier dossier <code>.json</code></label>
<input
ref={dossierInputRef}
type="file" accept=".json"
disabled={missingInv}
onChange={onFileChange}
/>
</div>
</div>
{dossierErr && <div className="error" style={{ marginTop: 10 }}>{dossierErr}</div>}
{/* Aperçu du dossier avant import */}
{dp && inv && (
<div style={{ marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 14 }}>
<h4 style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)' }}>Aperçu du dossier</h4>
<table style={{ marginBottom: 0 }}>
<tbody>
<tr><td style={{ width: 200 }}>Projet</td><td><strong>{inv.nom_projet}</strong></td></tr>
<tr><td>Plateforme</td><td>{dp.plateforme?.nom}</td></tr>
<tr><td>Date souscription</td><td>{fmtDate(inv.date_souscription)}</td></tr>
<tr><td>Montant investi</td><td>{inv.montant_investi} €</td></tr>
<tr><td>Statut</td><td>{inv.statut}</td></tr>
<tr><td>Remboursements</td><td>{dp.remboursements?.length ?? 0} enregistrement(s)</td></tr>
<tr><td>Projections</td><td>{dp.projections?.length ?? 0} échéance(s)</td></tr>
<tr><td>Historique</td><td>{dp.historique?.length ?? 0} entrée(s)</td></tr>
<tr><td>Exporté le</td><td className="text-muted" style={{ fontSize: 11 }}>{dp.exported_at}</td></tr>
</tbody>
</table>
<div style={{ marginTop: 12, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button onClick={() => { setDossierFile(null); setDossierPreview(null); if (dossierInputRef.current) dossierInputRef.current.value = ''; }}>
Annuler
</button>
<button className="primary" onClick={onImport} disabled={dossierBusy || missingInv}>
{dossierBusy ? '' : 'Importer ce dossier'}
</button>
</div>
</div>
)}
{dossierResult && (
<div className="success-msg" style={{ marginTop: 12 }}>
{dossierResult.action === 'created'
? ' Dossier créé avec succès.'
: ' Dossier mis à jour avec succès.'
}
{' '}
<button
style={{ marginLeft: 8, fontSize: 'var(--fs-xs)', padding: '2px 8px' }}
onClick={() => navigate(`/investissements/${dossierResult.investissementId}`)}
>
Ouvrir le dossier
</button>
</div>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';
export default function Login() {
const { login } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [err, setErr] = useState(null);
const [busy, setBusy] = useState(false);
const submit = async (e) => {
e.preventDefault();
setErr(null); setBusy(true);
try {
await login(email, password);
navigate('/');
} catch (e) {
setErr(e.message);
} finally { setBusy(false); }
};
return (
<div className="login-shell">
<form className="card login-card" onSubmit={submit}>
<h2 style={{ marginTop: 0 }}>Connexion</h2>
{err && <div className="error">{err}</div>}
<label>Email</label>
<input type="email" required value={email} onChange={e => setEmail(e.target.value)} />
<div style={{ height: 10 }} />
<label>Mot de passe</label>
<input type="password" required value={password} onChange={e => setPassword(e.target.value)} />
<div style={{ height: 16 }} />
<button className="primary" type="submit" disabled={busy} style={{ width: '100%' }}>
{busy ? '…' : 'Se connecter'}
</button>
<p className="text-muted" style={{ marginTop: 16, textAlign: 'center' }}>
Pas encore de compte ? <Link to="/register">Créer un compte</Link>
</p>
</form>
</div>
);
}
+286
View File
@@ -0,0 +1,286 @@
import { useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';
import { useUi } from '../context/UiContext.jsx';
import { api } from '../api.js';
/* ── Icônes nav ─────────────────────────────────────────────── */
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 IconLock() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>;
}
/* ── Dropdown custom style Finary ────────────────────────────── */
const LANGUES = [
{ value: 'fr', label: 'Français' },
{ value: 'en', label: 'English' },
];
const DEVISES = [
{ value: 'EUR', label: '€ - EUR' },
{ value: 'USD', label: '$ - USD' },
{ value: 'GBP', label: '£ - GBP' },
{ value: 'CHF', label: 'CHF' },
{ value: 'CAD', label: 'CA$ - CAD' },
{ value: 'SGD', label: 'SGD' },
];
function ProfileSelect({ label, options, value, onChange }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const h = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
const selected = options.find(o => o.value === value);
return (
<div ref={ref} className="profile-field">
<span className="profile-label">{label}</span>
<div className={`profile-select-trigger${open ? ' open' : ''}`}
onClick={() => setOpen(o => !o)} role="button" tabIndex={0}
onKeyDown={e => e.key === 'Enter' && setOpen(o => !o)}>
<span>{selected?.label}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ transition: 'transform .15s', transform: open ? 'rotate(180deg)' : 'rotate(0deg)' }}
aria-hidden="true">
<path d="M6 9l6 6 6-6"/>
</svg>
</div>
{open && (
<div className="profile-select-dropdown" role="listbox">
{options.map(o => (
<div key={o.value}
className={`profile-select-option${o.value === value ? ' selected' : ''}`}
role="option" aria-selected={o.value === value}
onClick={() => { onChange(o.value); setOpen(false); }}>
{o.label}
{o.value === value && (
<svg width="14" height="14" 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>
)}
</div>
))}
</div>
)}
</div>
);
}
/* ── Mon profil + Préférences ────────────────────────────────── */
function AccountForm() {
const { user, updateUser } = useAuth();
const { langue, setLangue, devise, setDevise } = useUi();
/* Découpe display_name en prénom / nom */
const parseName = (dn = '') => {
const parts = dn.trim().split(' ');
return parts.length >= 2
? { prenom: parts[0], nom: parts.slice(1).join(' ') }
: { prenom: dn.trim(), nom: '' };
};
const initial = parseName(user?.display_name);
const [prenom, setPrenom] = useState(initial.prenom);
const [nom, setNom] = useState(initial.nom);
const [infoMsg, setInfoMsg] = useState(null);
const [infoErr, setInfoErr] = useState(null);
const [loading, setLoading] = useState(false);
const save = async () => {
setInfoErr(null); setInfoMsg(null); setLoading(true);
try {
const displayName = [prenom.trim(), nom.trim()].filter(Boolean).join(' ');
await updateUser({ displayName });
setInfoMsg('Profil mis à jour.');
} catch (err) { setInfoErr(err.message); }
finally { setLoading(false); }
};
/* Sauvegarde auto à la perte du focus */
const handleBlur = () => save();
return (
<div className="profile-page">
{/* ── Mon profil ──────────────────────────────────────── */}
<section className="profile-section">
<h2 className="profile-section-title">Mon profil</h2>
{infoErr && <div className="error" style={{ marginBottom: 12 }}>{infoErr}</div>}
{infoMsg && <div className="success-msg" style={{ marginBottom: 12 }}>{infoMsg}</div>}
<div className="profile-grid-2">
<div className="profile-field">
<span className="profile-label">Prénom</span>
<input className="profile-input" value={prenom}
onChange={e => setPrenom(e.target.value)}
onBlur={handleBlur}
placeholder="Prénom" />
</div>
<div className="profile-field">
<span className="profile-label">Nom</span>
<input className="profile-input" value={nom}
onChange={e => setNom(e.target.value)}
onBlur={handleBlur}
placeholder="NOM" />
</div>
</div>
<div className="profile-field profile-field-full">
<span className="profile-label">Mon email</span>
<div className="profile-email-row">
<span className="profile-email-value">{user?.email}</span>
</div>
</div>
<div style={{ marginTop: 20 }}>
<button className="profile-manage-btn" type="button" disabled>
Gérer mon email
</button>
</div>
</section>
{/* ── Préférences ─────────────────────────────────────── */}
<section className="profile-section">
<h2 className="profile-section-title">Préférences</h2>
<div className="profile-grid-2">
<ProfileSelect label="Langue" options={LANGUES} value={langue} onChange={setLangue} />
<ProfileSelect label="Devise" options={DEVISES} value={devise} onChange={setDevise} />
</div>
</section>
{loading && <p className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>Enregistrement</p>}
</div>
);
}
/* ── Sécurité — Mot de passe ─────────────────────────────────── */
function SecurityForm() {
const { updateUser } = useAuth();
const [pwdForm, setPwdForm] = useState({ currentPassword: '', newPassword: '', confirm: '' });
const [pwdMsg, setPwdMsg] = useState(null);
const [pwdErr, setPwdErr] = useState(null);
const [pwdLoading, setPwdLoading] = useState(false);
const savePwd = async (e) => {
e.preventDefault(); setPwdErr(null); setPwdMsg(null);
if (pwdForm.newPassword !== pwdForm.confirm) { setPwdErr('Les mots de passe ne correspondent pas.'); return; }
if (pwdForm.newPassword.length < 8) { setPwdErr('8 caractères minimum.'); return; }
setPwdLoading(true);
try {
await updateUser({ currentPassword: pwdForm.currentPassword, newPassword: pwdForm.newPassword });
setPwdMsg('Mot de passe modifié avec succès.');
setPwdForm({ currentPassword: '', newPassword: '', confirm: '' });
} catch (err) { setPwdErr(err.message); }
finally { setPwdLoading(false); }
};
const handleBackfillComptes = async () => {
setLoadingBackfill(true);
setErrorMsg(null);
setSuccessMsg(null);
try {
const { updated, total } = await api.post('/remboursements/backfill-comptes', {});
if (updated === 0) {
setSuccessMsg(`Aucun remboursement à corriger (${total} vérifié${total > 1 ? 's' : ''}).`);
} else {
setSuccessMsg(`${updated} remboursement${updated > 1 ? 's' : ''} mis à jour sur ${total} vérifié${total > 1 ? 's' : ''}.`);
}
setShowBackfillModal(false);
} catch (err) {
setErrorMsg(err.message || 'Une erreur est survenue.');
setShowBackfillModal(false);
} finally {
setLoadingBackfill(false);
}
};
return (
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Mot de passe</h3>
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
Saisissez votre mot de passe actuel puis choisissez-en un nouveau (8 caractères minimum).
</p>
{pwdErr && <div className="error">{pwdErr}</div>}
{pwdMsg && <div className="success-msg">{pwdMsg}</div>}
<form onSubmit={savePwd}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 400 }}>
<div>
<label>Mot de passe actuel</label>
<input type="password" required autoComplete="current-password"
value={pwdForm.currentPassword}
onChange={e => setPwdForm({ ...pwdForm, currentPassword: e.target.value })} />
</div>
<div>
<label>Nouveau mot de passe</label>
<input type="password" required autoComplete="new-password"
value={pwdForm.newPassword}
onChange={e => setPwdForm({ ...pwdForm, newPassword: e.target.value })} />
</div>
<div>
<label>Confirmer le nouveau mot de passe</label>
<input type="password" required autoComplete="new-password"
value={pwdForm.confirm}
onChange={e => setPwdForm({ ...pwdForm, confirm: e.target.value })} />
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
<button className="primary" type="submit" disabled={pwdLoading}>
{pwdLoading ? 'Modification…' : 'Modifier le mot de passe'}
</button>
</div>
</form>
</div>
);
}
/* ── Page principale ─────────────────────────────────────────── */
export default function MonCompte() {
const { search } = useLocation();
const navigate = useNavigate();
const section = new URLSearchParams(search).get('section') || 'profil';
const setSection = (s) => navigate(`/compte?section=${s}`, { replace: true });
const SECTIONS = [
{ id: 'profil', label: 'Mon compte', icon: <IconUser /> },
{ id: 'securite', label: 'Sécurité', icon: <IconLock /> },
];
return (
<div className="account-layout">
{/* ── Nav gauche ───────────────────────────────────────── */}
<aside className="account-sidebar">
<h1 className="account-title">Mon compte</h1>
{SECTIONS.map(item => (
<button
key={item.id}
className={`account-nav-item${section === item.id ? ' active' : ''}`}
onClick={() => setSection(item.id)}
>
{item.icon}
{item.label}
</button>
))}
</aside>
{/* ── Contenu ─────────────────────────────────────── */}
<div className="account-content">
{section === 'profil' && <AccountForm />}
{section === 'securite' && <SecurityForm />}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+152
View File
@@ -0,0 +1,152 @@
import { useTheme } from '../context/ThemeContext.jsx';
import { useUi } from '../context/UiContext.jsx';
/* ── Theme options ─────────────────────────────────────────── */
const THEMES = [
{
mode: 'light',
label: 'Clair',
desc: 'Interface lumineuse',
preview: (
<svg viewBox="0 0 56 36" width="56" height="36" aria-hidden="true">
<rect width="56" height="36" rx="5" fill="#f0f4ff"/>
<rect x="2" y="2" width="12" height="32" rx="3" fill="#1e3a8a"/>
<rect x="16" y="2" width="38" height="8" rx="2" fill="#ffffff" opacity=".9"/>
<rect x="16" y="12" width="38" height="5" rx="2" fill="#c7d2e8"/>
<rect x="16" y="19" width="28" height="5" rx="2" fill="#c7d2e8"/>
<rect x="16" y="26" width="20" height="5" rx="2" fill="#c7d2e8"/>
</svg>
),
},
{
mode: 'dark',
label: 'Sombre',
desc: 'Interface nocturne',
preview: (
<svg viewBox="0 0 56 36" width="56" height="36" aria-hidden="true">
<rect width="56" height="36" rx="5" fill="#060e1f"/>
<rect x="2" y="2" width="12" height="32" rx="3" fill="#0d1629"/>
<rect x="16" y="2" width="38" height="8" rx="2" fill="#111c35" opacity=".9"/>
<rect x="16" y="12" width="38" height="5" rx="2" fill="#1e3a6a"/>
<rect x="16" y="19" width="28" height="5" rx="2" fill="#1e3a6a"/>
<rect x="16" y="26" width="20" height="5" rx="2" fill="#1e3a6a"/>
</svg>
),
},
{
mode: 'system',
label: 'Système',
desc: 'Suit les préférences OS',
preview: (
<svg viewBox="0 0 56 36" width="56" height="36" aria-hidden="true">
<defs>
<linearGradient id="split" x1="0" x2="1" y1="0" y2="0">
<stop offset="50%" stopColor="#f0f4ff"/>
<stop offset="50%" stopColor="#060e1f"/>
</linearGradient>
</defs>
<rect width="56" height="36" rx="5" fill="url(#split)"/>
<rect x="2" y="2" width="12" height="32" rx="3" fill="#1e3a8a"/>
<rect x="16" y="2" width="18" height="8" rx="2" fill="#ffffff" opacity=".9"/>
<rect x="36" y="2" width="18" height="8" rx="2" fill="#111c35" opacity=".9"/>
<rect x="16" y="12" width="18" height="5" rx="2" fill="#c7d2e8"/>
<rect x="36" y="12" width="18" height="5" rx="2" fill="#1e3a6a"/>
</svg>
),
},
];
/* ── Font scale options ────────────────────────────────────── */
const FONTS = [
{
scale: 'compact',
label: 'Normal',
desc: 'Interface compacte',
sizes: { body: 12, table: 11 },
},
{
scale: 'medium',
label: 'Moyen',
desc: 'Taille intermédiaire',
sizes: { body: 13, table: 12 },
},
{
scale: 'large',
label: 'Grand',
desc: 'Meilleure lisibilité',
sizes: { body: 14, table: 13 },
},
];
/* ── Component ─────────────────────────────────────────────── */
export default function Preferences() {
const { mode, setMode } = useTheme();
const { fontScale, setFontScale } = useUi();
return (
<>
<div className="topbar">
<h2>Interface</h2>
</div>
{/* ── Thème ──────────────────────────────────────────── */}
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Apparence</h3>
<p style={{ margin: '0 0 16px', color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>
Choisissez le thème visuel de l'application.
</p>
<div className="pref-options">
{THEMES.map((t) => (
<button
key={t.mode}
type="button"
className={`pref-option${mode === t.mode ? ' active' : ''}`}
onClick={() => setMode(t.mode)}
aria-pressed={mode === t.mode}
>
{t.preview}
<span style={{ fontWeight: 700, fontSize: 'var(--fs-sm)', marginTop: 4 }}>{t.label}</span>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>{t.desc}</span>
</button>
))}
</div>
</div>
{/* ── Police ─────────────────────────────────────────── */}
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Taille du texte</h3>
<p style={{ margin: '0 0 16px', color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>
Le niveau <strong>Grand</strong> est recommandé pour les personnes malvoyantes.
Les niveaux inférieurs permettent d'afficher plus de données à l'écran.
</p>
<div className="pref-options">
{FONTS.map((f) => (
<button
key={f.scale}
type="button"
className={`pref-option${fontScale === f.scale ? ' active' : ''}`}
onClick={() => setFontScale(f.scale)}
aria-pressed={fontScale === f.scale}
>
{/* Live-size text preview */}
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4,
width: '100%', padding: '8px 0 4px',
borderBottom: '1px solid var(--border)', marginBottom: 4,
}}>
<span className="font-preview" style={{ fontSize: f.sizes.body }}>
Aa — {f.label}
</span>
<span style={{ fontSize: f.sizes.table, color: 'var(--text-muted)' }}>
Tableau {f.sizes.table}px · Corps {f.sizes.body}px
</span>
</div>
<span style={{ fontWeight: 700, fontSize: 'var(--fs-sm)', marginTop: 2 }}>{f.label}</span>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>{f.desc}</span>
</button>
))}
</div>
</div>
</>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';
export default function Register() {
const { register } = useAuth();
const navigate = useNavigate();
const [form, setForm] = useState({ email: '', password: '', displayName: '' });
const [err, setErr] = useState(null);
const [busy, setBusy] = useState(false);
const set = (k) => (e) => setForm({ ...form, [k]: e.target.value });
const submit = async (e) => {
e.preventDefault();
setErr(null); setBusy(true);
try {
await register(form.email, form.password, form.displayName || undefined);
navigate('/');
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
};
return (
<div className="login-shell">
<form className="card login-card" onSubmit={submit}>
<h2 style={{ marginTop: 0 }}>Créer un compte</h2>
{err && <div className="error">{err}</div>}
<label>Nom d'affichage</label>
<input value={form.displayName} onChange={set('displayName')} placeholder="Olivier" />
<div style={{ height: 10 }} />
<label>Email</label>
<input type="email" required value={form.email} onChange={set('email')} />
<div style={{ height: 10 }} />
<label>Mot de passe (8 car. min.)</label>
<input type="password" required minLength={8} value={form.password} onChange={set('password')} />
<div style={{ height: 16 }} />
<button className="primary" type="submit" disabled={busy} style={{ width: '100%' }}>
{busy ? '' : 'Créer le compte'}
</button>
<p className="text-muted" style={{ marginTop: 16, textAlign: 'center' }}>
Déjà inscrit ? <Link to="/login">Se connecter</Link>
</p>
</form>
</div>
);
}
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
import { useLocation, useNavigate } from 'react-router-dom';
import FamilleEntreprises from './FamilleEntreprises.jsx';
import AppearanceSection from './settings/AppearanceSection.jsx';
import PlateformesSection from './settings/PlateformesSection.jsx';
import CategoriesInvSection from './settings/CategoriesInvSection.jsx';
import SecteursInvSection from './settings/SecteursInvSection.jsx';
import ComptesSection from './settings/ComptesSection.jsx';
import MaFiscaliteSection from './settings/MaFiscaliteSection.jsx';
import DataCleanupSection from './settings/DataCleanupSection.jsx';
import ImportsSection from './settings/ImportsSection.jsx';
/* ── Icônes nav ───────────────────────────────────────────────── */
function IconFamily() { 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>; }
function IconMonitor() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>; }
function IconServer() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>; }
function IconWallet() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M16 12h.01M2 10h20"/></svg>; }
function IconTag() { 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="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>; }
function IconLayers() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>; }
function IconGrid() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>; }
function IconMyFiscal() { 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="M20 7H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/><path d="M16 3H8l-2 4h12l-2-4z"/><line x1="12" y1="12" x2="12" y2="12.01"/></svg>; }
function IconBroom() { 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="M3 21l9-9"/><path d="M12.22 6.22L17 1.5l5.5 5.5-4.72 4.78"/><path d="M5 17c.5-2 2-3.5 4-4.5l3.5 3.5c-1 2-2.5 3.5-4.5 4"/></svg>; }
function IconUpload() { 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="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>; }
const NAV = [
{
group: 'Interface',
items: [
{ id: 'apparence', label: 'Apparence', icon: <IconMonitor /> },
],
},
{
group: 'Mon paramétrage',
items: [
{ id: 'membres', label: 'Mes membres & entreprises', icon: <IconFamily /> },
{ id: 'plateformes', label: 'Mes plateformes', icon: <IconServer /> },
{ id: 'comptes', label: 'Mes comptes courants', icon: <IconWallet /> },
{ id: 'ma-fiscalite', label: 'Ma fiscalité', icon: <IconMyFiscal /> },
],
},
{
group: 'Mes tags',
items: [
{ id: 'categories-inv', label: "Mes catégories d'investissement", icon: <IconLayers /> },
{ id: 'secteurs-inv', label: "Mes secteurs d'investissement", icon: <IconGrid /> },
],
},
{
group: 'Mes données',
items: [
{ id: 'nettoyage', label: 'Nettoyage de données', icon: <IconBroom /> },
{ id: 'imports', label: 'Importation de données', icon: <IconUpload /> },
],
},
];
export default function Settings() {
const { search } = useLocation();
const navigate = useNavigate();
const section = new URLSearchParams(search).get('section') || 'apparence';
const setSection = (s) => navigate(`/settings?section=${s}`, { replace: true });
return (
<div className="account-layout">
<aside className="account-sidebar">
<h1 className="account-title">Gérer les paramètres</h1>
{NAV.map(group => (
<div key={group.group} className="account-nav-group">
<span className="account-nav-label">{group.group}</span>
{group.items.map(item => (
<button
key={item.id}
className={`account-nav-item${section === item.id ? ' active' : ''}`}
onClick={() => setSection(item.id)}
>
{item.icon}
{item.label}
</button>
))}
</div>
))}
</aside>
<div className="account-content">
{section === 'apparence' && <AppearanceSection />}
{section === 'membres' && <FamilleEntreprises />}
{section === 'plateformes' && <PlateformesSection />}
{section === 'comptes' && <ComptesSection />}
{section === 'ma-fiscalite' && <MaFiscaliteSection />}
{section === 'categories-inv' && <CategoriesInvSection />}
{section === 'secteurs-inv' && <SecteursInvSection />}
{section === 'nettoyage' && <DataCleanupSection />}
{section === 'imports' && <ImportsSection />}
</div>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useState } from 'react';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { fmtEUR, fmtDate } from '../utils/format.js';
export default function SimulRemboursements() {
const { activeId } = useInvestisseur();
const [investissements, setInvestissements] = useState([]);
const [selected, setSelected] = useState('');
const [echeances, setEcheances] = useState([]);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState(null);
useEffect(() => {
if (!activeId) return;
api.get('/investissements').then(setInvestissements);
}, [activeId]);
useEffect(() => {
if (!selected) { setEcheances([]); return; }
api.get('/simul', { investissement_id: selected }).then(setEcheances);
}, [selected]);
const generate = async () => {
if (!selected) return;
setBusy(true); setMsg(null);
try {
const r = await api.post('/simul/generate', {
investissement_id: Number(selected),
replace: true,
});
setMsg(`${r.inserted} échéances générées.`);
const e = await api.get('/simul', { investissement_id: selected });
setEcheances(e);
} catch (e) {
setMsg(`${e.message}`);
} finally { setBusy(false); }
};
const inv = investissements.find(i => i.id === Number(selected));
const totals = echeances.reduce((acc, e) => {
acc.capital += e.capital_prevu;
acc.interets += e.interets_prevus;
acc.total += e.total_prevu;
return acc;
}, { capital: 0, interets: 0, total: 0 });
return (
<>
<div className="topbar"><h2>Projections Remboursements</h2></div>
<div className="card">
<div className="row">
<div style={{ flex: 2 }}>
<label>Investissement</label>
<select value={selected} onChange={e => setSelected(e.target.value)}>
<option value=""> Choisir </option>
{investissements.map(i =>
<option key={i.id} value={i.id}>
{i.nom_projet} ({fmtEUR(i.montant_investi)} {i.taux_interet ?? '?'}% {i.duree_mois ?? '?'}m {i.type_remb || '?'})
</option>
)}
</select>
</div>
<div style={{ flex: 1 }}>
<button className="primary" onClick={generate} disabled={!selected || busy} style={{ width: '100%' }}>
{busy ? '…' : 'Générer / Régénérer'}
</button>
</div>
</div>
{msg && <div className={msg.startsWith('✔') ? 'success-msg' : 'error'} style={{ marginTop: 12 }}>{msg}</div>}
{inv && (!inv.taux_interet || !inv.duree_mois) && (
<div className="error" style={{ marginTop: 12 }}>
Cet investissement n'a pas de <strong>taux</strong> et/ou de <strong>durée</strong>. Renseignez-les dans la fiche.
</div>
)}
</div>
{echeances.length > 0 && (
<div className="card">
<div className="kpi-grid" style={{ marginBottom: 12 }}>
<div className="kpi"><div className="label">Capital prévu</div><div className="value">{fmtEUR(totals.capital)}</div></div>
<div className="kpi"><div className="label">Intérêts prévus</div><div className="value success">{fmtEUR(totals.interets)}</div></div>
<div className="kpi"><div className="label">Total prévu</div><div className="value">{fmtEUR(totals.total)}</div></div>
</div>
<table>
<thead>
<tr>
<th>#</th><th>Date prévue</th>
<th className="num">Capital</th><th className="num">Intérêts</th>
<th className="num">Total échéance</th>
</tr>
</thead>
<tbody>
{echeances.map(e => (
<tr key={e.id}>
<td>{e.numero_echeance}</td>
<td>{fmtDate(e.date_prevue)}</td>
<td className="num">{fmtEUR(e.capital_prevu)}</td>
<td className="num">{fmtEUR(e.interets_prevus)}</td>
<td className="num">{fmtEUR(e.total_prevu)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
);
}
+305
View File
@@ -0,0 +1,305 @@
import { useEffect, useRef, useState } from 'react';
import PageIcon from '../components/PageIcon.jsx';
import Pagination from '../components/Pagination.jsx';
import { usePagination } from '../hooks/usePagination.js';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useUi } from '../context/UiContext.jsx';
import { fmtEUR, fmtStatut } from '../utils/format.js';
import Cerfa2561Preview from '../components/Cerfa2561Preview.jsx';
import CerfaRecapTable from '../components/CerfaRecapTable.jsx';
import Cerfa2778Preview from '../components/Cerfa2778Preview.jsx';
import Cerfa2042Preview from '../components/Cerfa2042Preview.jsx';
/* ── YearSelector ────────────────────────────────────────────── */
function YearSelector({ annee, setAnnee, availableYears }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
return (
<div ref={ref} style={{ position: 'relative', flexShrink: 0, width: 160 }}>
<div
onClick={() => setOpen(v => !v)}
style={{
height: '100%', boxSizing: 'border-box',
background: 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)',
borderRadius: 10,
padding: '12px 16px',
boxShadow: open
? '0 6px 28px rgba(109,40,217,0.45)'
: '0 4px 20px rgba(109,40,217,0.30)',
cursor: 'pointer',
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
userSelect: 'none',
transition: 'box-shadow .15s',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{
fontSize: 'var(--fs-xs)', textTransform: 'uppercase',
letterSpacing: '.06em', color: 'rgba(255,255,255,0.7)', fontWeight: 500,
}}>Année</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="rgba(255,255,255,0.7)" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<div style={{ color: '#fff', fontSize: '1.8rem', fontWeight: 700, lineHeight: 1.1, marginTop: 6 }}>
{annee}
</div>
</div>
{open && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 200,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 10, boxShadow: '0 8px 28px rgba(0,0,0,0.15)',
minWidth: 160, overflow: 'hidden',
}}>
{availableYears.map((yr, i) => {
const isActive = String(yr) === String(annee);
return (
<div
key={yr}
onClick={() => { setAnnee(String(yr)); setOpen(false); }}
style={{
padding: '10px 16px', cursor: 'pointer',
background: isActive ? 'rgba(109,40,217,0.08)' : 'transparent',
color: isActive ? '#7c3aed' : 'var(--text)',
fontWeight: isActive ? 700 : 400,
fontSize: 'var(--fs-sm)',
borderBottom: i < availableYears.length - 1 ? '1px solid var(--border)' : 'none',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
transition: 'background .1s',
}}
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--surface-2)'; }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
>
<span>{yr}</span>
{isActive && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="#7c3aed" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
</div>
);
})}
</div>
)}
</div>
);
}
/* ── ExportDropdown ──────────────────────────────────────────── */
function ExportDropdown({ disabled, onCSV, onJSON }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
const choose = fn => { setOpen(false); fn(); };
return (
<div ref={ref} style={{ position: 'relative' }}>
<button type="button" className="icon-btn" disabled={disabled}
onClick={() => setOpen(o => !o)} title="Exporter">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
{open && (
<div className="export-dropdown" role="menu">
<button role="menuitem" onClick={() => choose(onCSV)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
<line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/>
</svg>
<span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
</button>
<button role="menuitem" onClick={() => choose(onJSON)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
<path d="M8 13h1.5a1 1 0 0 1 1 1v1a1 1 0 0 0 1 1 1 1 0 0 0-1 1v1a1 1 0 0 1-1 1H8"/>
<path d="M16 13h-1.5a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1H16"/>
</svg>
<span><strong>Format JSON</strong><small>Réimportable, structuré</small></span>
</button>
</div>
)}
</div>
);
}
/* ── Composant principal ─────────────────────────────────────── */
export default function TaxReport() {
const { activeId, activeView } = useInvestisseur();
const { pfoAssujetti } = useUi();
const [annee, setAnnee] = useState(String(new Date().getFullYear()));
const [availableYears, setAvailableYears] = useState([]);
const [data, setData] = useState(null);
const [data2042, setData2042] = useState(null);
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState('2042');
const [detailExpanded, setDetailExpanded] = useState(false);
const [cerfaExpanded, setCerfaExpanded] = useState(false);
/* ── Chargement des années disponibles ── */
useEffect(() => {
if (!activeId && activeView !== 'all') return;
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
api.get('/taxreport/years', scopeParams).then(years => {
setAvailableYears(years);
// Si l'année courante n'est pas dans la liste, prendre la première disponible
if (years.length > 0 && !years.map(String).includes(annee)) {
setAnnee(String(years[0]));
}
});
}, [activeId, activeView]); // eslint-disable-line
const load = () => {
if (!activeId && activeView !== 'all') return;
setData(null);
setLoading(true);
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
const LS_EXCL = 'cl_2778_excluded_plats';
const excluded = new Set(JSON.parse(localStorage.getItem(LS_EXCL) ?? '[]'));
Promise.all([
api.get('/taxreport', { annee, ...scopeParams }),
api.get('/taxreport/cerfa2561', { annee, ...scopeParams }),
api.get('/taxreport/2778', { annee, ...scopeParams }),
]).then(([d, d2561, d2778]) => {
setData(d);
const frLignes = (d2561?.lignes ?? []).filter(l => l.domiciliation === 'FR');
const platEtr = (d2778?.plateformes ?? []).filter(p => !excluded.has(p.id));
const etrBA = p => Object.values(p.mois ?? {}).reduce((s, v) => s + v, 0);
const pfo = 0.128; // taux par défaut — affiné par pfuList si dispo
setData2042({
case_2TT: frLignes.reduce((s, l) => s + (l.case_2TT ?? 0), 0),
case_2TR: frLignes.reduce((s, l) => s + (l.case_2TR ?? 0), 0) + Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0)),
case_2BH: frLignes.reduce((s, l) => s + (l.case_2BH ?? 0), 0) + Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0)),
case_2CK: frLignes.reduce((s, l) => s + (l.case_2CK ?? 0), 0) + Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0) * pfo),
case_2TY: frLignes.reduce((s, l) => s + (l.case_2TY ?? 0), 0),
});
}).finally(() => setLoading(false));
};
useEffect(load, [activeId, activeView, annee]); // eslint-disable-line
/* ── Pagination détail ── */
const detail = data?.detail ?? [];
const {
pagedItems: pagedDetail, page: detPage, setPage: setDetPage,
pageSize: detPageSize, setPageSize: setDetPageSize,
totalPages: detTotalPages, totalItems: detTotalItems, PAGE_SIZES,
} = usePagination(detail, 'cl_pagesize_fiscal_detail', [activeTab]);
/* ── Exports ── */
const dlBlob = (content, filename, type) => {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
};
const downloadCsv = () => {
const token = localStorage.getItem('cl_token');
const investisseurId = localStorage.getItem('cl_investisseur_id');
const exportParams = activeView === 'all' ? { annee, scope: 'all' } : { annee };
fetch(api.exportUrl('/taxreport/export', exportParams), {
headers: {
Authorization: `Bearer ${token}`,
'X-Investisseur-Id': investisseurId,
},
})
.then(r => r.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `2778-SD-${annee}.csv`;
a.click();
URL.revokeObjectURL(url);
});
};
const downloadJson = () => {
if (!data) return;
const payload = {
annee: data.annee,
recap: data.recap,
cases: data.cases,
detail: data.detail,
pertes: data.pertes,
pertesTotales: data.pertesTotales,
};
dlBlob(JSON.stringify(payload, null, 2), `2778-SD-${annee}.json`, 'application/json');
};
return (
<>
<div className="topbar">
<h2><PageIcon name="tax" />Fiscalité Récapitulatif fiscal</h2>
<YearSelector annee={annee} setAnnee={setAnnee} availableYears={availableYears} />
</div>
{loading || !data ? (
<div className="card text-muted">Chargement</div>
) : (
<>
{!detailExpanded && !cerfaExpanded && data2042 && <div className="card">
<h3 style={{ marginTop: 0 }}>Cases fiscales 2042 synthèse {annee}</h3>
<div className="kpi-grid">
{data2042.case_2TT > 0 && <div className="kpi"><div className="label">Case 2TT Prêts participatifs (FR)</div><div className="value">{fmtEUR(data2042.case_2TT)}</div></div>}
{data2042.case_2TR > 0 && <div className="kpi"><div className="label">Case 2TR Revenus fixes (FR + étranger)</div><div className="value">{fmtEUR(data2042.case_2TR)}</div></div>}
<div className="kpi"><div className="label">Case 2BH Base CSG/CRDS</div><div className="value">{fmtEUR(data2042.case_2BH)}</div></div>
<div className="kpi"><div className="label">Case 2CK Crédit d'impôt</div><div className="value" style={{ color: 'var(--success)' }}>{fmtEUR(data2042.case_2CK)}</div></div>
{data2042.case_2TY > 0 && <div className="kpi"><div className="label">Case 2TY — Pertes en capital</div><div className="value danger">{fmtEUR(data2042.case_2TY)}</div></div>}
</div>
<p className="text-muted" style={{ marginTop: 12, fontSize: 12 }}>
⚠ Cases indicatives combinant plateformes françaises (IFU automatique) et étrangères. Référez-vous à la notice 2041-GFI.
</p>
</div>}
{!detailExpanded && !cerfaExpanded && (
<div className="dr-tabs">
<button className={`dr-tab${activeTab === '2042' ? ' active' : ''}`} onClick={() => setActiveTab('2042')}>CERFA 2042</button>
<button className={`dr-tab${activeTab === 'cerfa' ? ' active' : ''}`} onClick={() => setActiveTab('cerfa')}>CERFA 2561 (IFU)</button>
{pfoAssujetti && <button className={`dr-tab${activeTab === '2778' ? ' active' : ''}`} onClick={() => setActiveTab('2778')}>CERFA 2778-SD</button>}
</div>
)}
</>
)}
{activeTab === '2042' && !detailExpanded && !cerfaExpanded && data && (
<Cerfa2042Preview annee={annee} activeView={activeView} pfoAssujetti={pfoAssujetti} />
)}
{(activeTab === 'cerfa' || cerfaExpanded) && !detailExpanded && data && (
<Cerfa2561Preview
annee={annee} activeView={activeView} inline
expanded={cerfaExpanded}
onToggleExpand={() => { setCerfaExpanded(e => !e); setActiveTab('cerfa'); }}
/>
)}
{activeTab === '2778' && pfoAssujetti && !detailExpanded && !cerfaExpanded && (
<Cerfa2778Preview annee={annee} activeView={activeView} />
)}
</>
);
}
@@ -0,0 +1,69 @@
import { useState } from 'react';
import { api } from '../../api.js';
export default function CreateUserSection({ onCreated }) {
const [form, setForm] = useState({ email: '', password: '', displayName: '', role: 'user' });
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(null);
const [err, setErr] = useState(null);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true); setErr(null); setSuccess(null);
try {
const created = await api.post('/admin/users', {
email: form.email,
password: form.password,
displayName: form.displayName || undefined,
role: form.role,
});
setSuccess(`Utilisateur "${created.display_name || created.email}" créé avec succès.`);
setForm({ email: '', password: '', displayName: '', role: 'user' });
onCreated?.();
} catch (e) { setErr(e.message); }
finally { setLoading(false); }
};
return (
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Créer un utilisateur</h3>
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
Créez un nouveau compte manuellement sur la plateforme.
</p>
{success && <div className="success-msg" style={{ marginBottom: 16 }}>{success}</div>}
{err && <div className="error" style={{ marginBottom: 16 }}>{err}</div>}
<form onSubmit={handleSubmit} style={{ maxWidth: 480 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label>Nom affiché</label>
<input value={form.displayName} onChange={e => set('displayName', e.target.value)} placeholder="Prénom Nom" />
</div>
<div>
<label>Email *</label>
<input type="email" required value={form.email} onChange={e => set('email', e.target.value)} placeholder="utilisateur@exemple.com" />
</div>
<div>
<label>Mot de passe *</label>
<input type="password" required minLength={8} value={form.password} onChange={e => set('password', e.target.value)} placeholder="8 caractères minimum" />
</div>
<div>
<label>Rôle</label>
<select value={form.role} onChange={e => set('role', e.target.value)}>
<option value="user">Utilisateur</option>
<option value="admin">Administrateur</option>
</select>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 20 }}>
<button type="submit" className="primary" disabled={loading}>
{loading ? 'Création…' : 'Créer l\'utilisateur'}
</button>
</div>
</form>
</div>
);
}
+240
View File
@@ -0,0 +1,240 @@
import { useState, useEffect, useCallback } from 'react';
import { api } from '../../api.js';
const ICONS_BASE = '/api/icons-files/';
function IconPlus() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" 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>;
}
function IconUpload() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>;
}
function IconDownload() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>;
}
function IconHistory() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.95"/></svg>;
}
export default function IconsSection() {
const [icons, setIcons] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [uploading, setUploading] = useState(null);
const [history, setHistory] = useState(null); // { name, rows } | null
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState('');
const [newFile, setNewFile] = useState(null);
const [createErr, setCreateErr] = useState(null);
const [createOk, setCreateOk] = useState(null);
const load = useCallback(async () => {
setLoading(true);
try { setIcons(await api.get('/icons')); }
catch { setErr('Erreur de chargement'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
async function handleReplace(name) {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.svg,.png,.jpg,.jpeg,.webp';
input.onchange = async () => {
const file = input.files[0];
if (!file) return;
setUploading(name);
try {
const fd = new FormData();
fd.append('file', file);
const token = localStorage.getItem('cl_token');
const res = await fetch(`/api/icons/${name}`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}` },
body: fd,
});
if (!res.ok) { const j = await res.json(); throw new Error(j.error); }
await load();
} catch (e) { setErr(e.message); }
finally { setUploading(null); }
};
input.click();
}
async function loadHistory(name) {
try {
const rows = await api.get(`/icons/${name}/history`);
setHistory({ name, rows });
} catch { setErr('Erreur historique'); }
}
async function handleCreate(e) {
e.preventDefault();
setCreateErr(null); setCreateOk(null);
if (!newName.trim()) return setCreateErr('Nom requis');
if (!newFile) return setCreateErr('Fichier requis');
try {
const fd = new FormData();
fd.append('name', newName.trim().toLowerCase());
fd.append('description', newDesc.trim());
fd.append('file', newFile);
const token = localStorage.getItem('cl_token');
const res = await fetch('/api/icons', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: fd,
});
const json = await res.json();
if (!res.ok) throw new Error(json.error);
setCreateOk(`Icône "${json.name}" créée.`);
setNewName(''); setNewDesc(''); setNewFile(null);
setCreating(false);
await load();
} catch (e) { setCreateErr(e.message); }
}
if (loading) return <p className="text-muted" style={{ padding: 24 }}>Chargement</p>;
return (
<div>
<div className="topbar" style={{ marginBottom: 20 }}>
<div>
<h2 style={{ margin: 0 }}>Bibliothèque d'icônes</h2>
<p className="text-muted" style={{ margin: '4px 0 0', fontSize: 'var(--fs-sm)' }}>
{icons.length} icône{icons.length !== 1 ? 's' : ''} — les noms sont les clés utilisées par l'application.
</p>
</div>
<button className="btn btn-primary" onClick={() => { setCreating(v => !v); setCreateErr(null); setCreateOk(null); }}>
<IconPlus /> Nouvelle icône
</button>
</div>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
{creating && (
<div className="card" style={{ marginBottom: 20 }}>
<h3 style={{ margin: '0 0 14px' }}>Nouvelle association nom / image</h3>
<form onSubmit={handleCreate}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
<div>
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>
Nom (slug) *
</label>
<input
className="form-input"
placeholder="ex: taux-defaut"
value={newName}
onChange={e => setNewName(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
/>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>lettres minuscules, chiffres, tirets</span>
</div>
<div>
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>
Description
</label>
<input
className="form-input"
placeholder="ex: Taux de défaut"
value={newDesc}
onChange={e => setNewDesc(e.target.value)}
/>
</div>
</div>
<div style={{ marginBottom: 14 }}>
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>
Fichier image * (SVG, PNG, JPG, WebP 2 Mo max)
</label>
<input
type="file"
accept=".svg,.png,.jpg,.jpeg,.webp"
onChange={e => setNewFile(e.target.files[0] || null)}
/>
{newFile && <span style={{ marginLeft: 8, fontSize: 12, color: 'var(--text-muted)' }}>{newFile.name}</span>}
</div>
{createErr && <div className="error" style={{ marginBottom: 8 }}>{createErr}</div>}
{createOk && <div className="success" style={{ marginBottom: 8 }}>{createOk}</div>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary">Créer</button>
<button type="button" className="btn btn-secondary" onClick={() => setCreating(false)}>Annuler</button>
</div>
</form>
</div>
)}
<div className="icons-grid">
{icons.map(icon => (
<div key={icon.name} className="icon-card">
<div className="icon-card-preview">
<img
src={`${ICONS_BASE}${icon.filename}`}
alt={icon.name}
style={{ width: 48, height: 48, objectFit: 'contain' }}
/>
</div>
<div className="icon-card-body">
<span className="icon-card-name">{icon.name}</span>
{icon.description && (
<span className="icon-card-desc">{icon.description}</span>
)}
<span className="icon-card-file">{icon.filename}</span>
</div>
<div className="icon-card-actions">
<button
className="btn btn-sm btn-secondary"
onClick={() => handleReplace(icon.name)}
disabled={uploading === icon.name}
title="Remplacer l'image"
>
{uploading === icon.name ? '…' : <><IconUpload /> Remplacer</>}
</button>
<a
className="btn btn-sm btn-ghost"
href={`${ICONS_BASE}${icon.filename}`}
download={icon.filename}
title="Télécharger le fichier nettoyé"
>
<IconDownload />
</a>
<button
className="btn btn-sm btn-ghost"
onClick={() => history?.name === icon.name ? setHistory(null) : loadHistory(icon.name)}
title="Historique des versions"
>
<IconHistory />
</button>
</div>
{history?.name === icon.name && (
<div className="icon-history">
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)' }}>
Historique ({history.rows.length} version{history.rows.length !== 1 ? 's' : ''})
</span>
{history.rows.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>Aucune version précédente</span>
) : (
<div className="icon-history-list">
{history.rows.map(h => (
<div key={h.id} className="icon-history-row">
<img
src={`${ICONS_BASE}${h.filename}`}
alt="prev"
style={{ width: 28, height: 28, objectFit: 'contain', opacity: .7 }}
/>
<span style={{ fontSize: 11, color: 'var(--text-muted)', flex: 1 }}>{h.filename}</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{new Date(h.replaced_at).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { useState, useEffect, useCallback } from 'react';
import { api } from '../../api.js';
import { fmt, StatusBadge } from './adminHelpers.jsx';
const KNOWN_JOBS = [
{ name: 'auto_statut_retard', label: 'Passage automatique en retard' },
];
export default function JobLogsSection() {
const [logs, setLogs] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState(null);
const [runResult, setRunResult] = useState(null);
const [err, setErr] = useState(null);
const [page, setPage] = useState(0);
const PER_PAGE = 20;
const load = useCallback(async () => {
try {
setLoading(true);
const data = await api.get('/admin/job-logs', { limit: PER_PAGE, offset: page * PER_PAGE });
setLogs(data.rows);
setTotal(data.total);
} catch (e) { setErr(e.message); }
finally { setLoading(false); }
}, [page]);
useEffect(() => { load(); }, [load]);
const runJob = async (jobName) => {
setRunning(jobName); setRunResult(null);
try {
const r = await api.post(`/admin/jobs/${jobName}/run`, {});
setRunResult({ ok: true, msg: `Exécution terminée — ${r.nb_changes} modification(s)` });
load();
} catch (e) {
setRunResult({ ok: false, msg: e.message });
} finally { setRunning(null); }
};
const pages = Math.ceil(total / PER_PAGE);
return (
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Logs des jobs automatiques</h3>
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
Historique d'exécution des tâches planifiées et lancement manuel.
</p>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', marginBottom: 20, flexWrap: 'wrap' }}>
{KNOWN_JOBS.map(j => (
<button
key={j.name}
className="btn btn-outline"
style={{ display: 'flex', alignItems: 'center', gap: 7 }}
disabled={running === j.name}
onClick={() => runJob(j.name)}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polygon points="4,2 14,8 4,14"/>
</svg>
{running === j.name ? 'Exécution' : `Lancer : ${j.label}`}
</button>
))}
{runResult && (
<span style={{
fontSize: 13, padding: '4px 12px', borderRadius: 6,
background: runResult.ok ? 'rgba(34,197,94,.1)' : 'rgba(239,68,68,.1)',
color: runResult.ok ? '#16a34a' : '#dc2626',
border: `1px solid ${runResult.ok ? 'rgba(34,197,94,.3)' : 'rgba(239,68,68,.3)'}`,
}}>
{runResult.msg}
</span>
)}
</div>
{loading && <p style={{ color: 'var(--text-muted)' }}>Chargement…</p>}
{err && <p style={{ color: '#ef4444' }}>{err}</p>}
{!loading && !err && !logs.length && <p style={{ color: 'var(--text-muted)' }}>Aucun log disponible.</p>}
{logs.length > 0 && (
<>
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 12 }}>
{total} entrée{total > 1 ? 's' : ''} au total
</p>
<table style={{ fontSize: 13 }}>
<thead>
<tr>
<th>Date</th><th>Job</th><th>Statut</th>
<th className="num">Modifs</th><th>Détails</th><th>Erreur</th>
</tr>
</thead>
<tbody>
{logs.map(l => (
<tr key={l.id}>
<td style={{ whiteSpace: 'nowrap', color: 'var(--text-muted)' }}>{fmt(l.run_at)}</td>
<td style={{ fontFamily: 'monospace', fontSize: 12 }}>{l.job_name}</td>
<td><StatusBadge status={l.status} /></td>
<td className="num">
{l.nb_changes > 0
? <span style={{ fontWeight: 700, color: '#f97316' }}>{l.nb_changes}</span>
: <span style={{ color: 'var(--text-muted)' }}>0</span>
}
</td>
<td style={{ maxWidth: 280, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={l.details || ''}>
{l.details || <span style={{ color: 'var(--text-muted)' }}>—</span>}
</td>
<td style={{ color: '#ef4444', fontSize: 12 }}>
{l.error_msg || <span style={{ color: 'var(--text-muted)' }}>—</span>}
</td>
</tr>
))}
</tbody>
</table>
{pages > 1 && (
<div style={{ display: 'flex', gap: 8, marginTop: 16, alignItems: 'center' }}>
<button className="btn btn-sm btn-outline" disabled={page === 0} onClick={() => setPage(p => p - 1)}>← Précédent</button>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>Page {page + 1} / {pages}</span>
<button className="btn btn-sm btn-outline" disabled={page >= pages - 1} onClick={() => setPage(p => p + 1)}>Suivant </button>
</div>
)}
</>
)}
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
import { useState, useEffect, useCallback } from 'react';
import { api } from '../../api.js';
import ConfirmModal from '../../components/ConfirmModal.jsx';
import { fmt, Badge } from './adminHelpers.jsx';
export default function UsersSection({ currentUserId }) {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [confirmAction, setConfirmAction] = useState(null);
const load = useCallback(async () => {
try {
setLoading(true);
const data = await api.get('/admin/users');
setUsers(data);
} catch (e) { setErr(e.message); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const toggleRole = (u) => {
const newRole = u.role === 'admin' ? 'user' : 'admin';
setConfirmAction({
title: 'Changer le rôle',
message: `Changer le rôle de ${u.display_name || u.email}${newRole === 'admin' ? 'Administrateur' : 'Utilisateur'} ?`,
confirmLabel: 'Confirmer',
onConfirm: async () => {
try {
await api.patch(`/admin/users/${u.id}/role`, { role: newRole });
load();
} catch (e) { setErr('Erreur : ' + e.message); }
finally { setConfirmAction(null); }
},
});
};
const deleteUser = (u) => {
setConfirmAction({
title: 'Supprimer l\'utilisateur',
message: `Supprimer définitivement ${u.display_name || u.email} ? Toutes ses données seront effacées.`,
onConfirm: async () => {
try {
await api.del(`/admin/users/${u.id}`);
load();
} catch (e) { setErr('Erreur : ' + e.message); }
finally { setConfirmAction(null); }
},
});
};
if (loading) return <p style={{ color: 'var(--text-muted)' }}>Chargement</p>;
if (err) return <p style={{ color: '#ef4444' }}>{err}</p>;
return (
<>
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Comptes utilisateurs</h3>
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
{users.length} utilisateur{users.length !== 1 ? 's' : ''} enregistré{users.length !== 1 ? 's' : ''}
</p>
<table>
<thead>
<tr>
<th style={{ width: 36 }}>ID</th>
<th>Nom</th>
<th>Email</th>
<th>Rôle</th>
<th>Créé le</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
<td style={{ color: 'var(--text-muted)' }}>{u.id}</td>
<td style={{ fontWeight: 500 }}>{u.display_name || <em style={{ color: 'var(--text-muted)' }}></em>}</td>
<td>{u.email}</td>
<td><Badge role={u.role} /></td>
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmt(u.created_at)}</td>
<td>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn btn-sm btn-outline"
onClick={() => toggleRole(u)}
disabled={u.id === currentUserId && u.role === 'admin'}
title={u.id === currentUserId ? 'Vous ne pouvez pas vous rétrograder' : ''}
>
{u.role === 'admin' ? '→ Utilisateur' : '→ Admin'}
</button>
{u.id !== currentUserId && (
<button className="btn btn-sm btn-danger" onClick={() => deleteUser(u)}>
Supprimer
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<ConfirmModal
open={!!confirmAction}
title={confirmAction?.title}
message={confirmAction?.message}
confirmLabel={confirmAction?.confirmLabel}
onConfirm={confirmAction?.onConfirm}
onCancel={() => setConfirmAction(null)}
/>
</>
);
}
+41
View File
@@ -0,0 +1,41 @@
/* ── Helpers partagés Admin ───────────────────────────────────── */
export function fmt(iso) {
if (!iso) return '—';
const utc = iso.includes('T') || iso.endsWith('Z')
? iso
: iso.replace(' ', 'T') + 'Z';
return new Date(utc).toLocaleString('fr-FR', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
export function Badge({ role }) {
const isAdmin = role === 'admin';
return (
<span style={{
display: 'inline-block', padding: '2px 10px', borderRadius: 12,
fontSize: 11, fontWeight: 700, letterSpacing: '.04em',
background: isAdmin ? 'rgba(234,179,8,.15)' : 'rgba(100,116,139,.15)',
color: isAdmin ? '#ca8a04' : '#64748b',
border: `1px solid ${isAdmin ? 'rgba(234,179,8,.35)' : 'rgba(100,116,139,.25)'}`,
}}>
{isAdmin ? 'Admin' : 'Utilisateur'}
</span>
);
}
export function StatusBadge({ status }) {
const ok = status === 'ok';
return (
<span style={{
display: 'inline-block', padding: '2px 10px', borderRadius: 12,
fontSize: 11, fontWeight: 700,
background: ok ? 'rgba(34,197,94,.12)' : 'rgba(239,68,68,.12)',
color: ok ? '#16a34a' : '#dc2626',
border: `1px solid ${ok ? 'rgba(34,197,94,.3)' : 'rgba(239,68,68,.3)'}`,
}}>
{ok ? 'OK' : 'Erreur'}
</span>
);
}
@@ -0,0 +1,343 @@
import { useState, useEffect } from 'react';
import { useTheme } from '../../context/ThemeContext.jsx';
import { useUi } from '../../context/UiContext.jsx';
import { api } from '../../api.js';
/* ── Apparence — données ─────────────────────────────────────── */
const THEMES = [
{
mode: 'light', label: 'Clair', desc: 'Interface lumineuse',
preview: (
<svg viewBox="0 0 56 36" width="56" height="36" aria-hidden="true">
<rect width="56" height="36" rx="5" fill="#f0f4ff"/>
<rect x="2" y="2" width="12" height="32" rx="3" fill="#1e3a8a"/>
<rect x="16" y="2" width="38" height="8" rx="2" fill="#ffffff" opacity=".9"/>
<rect x="16" y="12" width="38" height="5" rx="2" fill="#c7d2e8"/>
<rect x="16" y="19" width="28" height="5" rx="2" fill="#c7d2e8"/>
<rect x="16" y="26" width="20" height="5" rx="2" fill="#c7d2e8"/>
</svg>
),
},
{
mode: 'dark', label: 'Sombre', desc: 'Interface nocturne',
preview: (
<svg viewBox="0 0 56 36" width="56" height="36" aria-hidden="true">
<rect width="56" height="36" rx="5" fill="#060e1f"/>
<rect x="2" y="2" width="12" height="32" rx="3" fill="#0d1629"/>
<rect x="16" y="2" width="38" height="8" rx="2" fill="#111c35" opacity=".9"/>
<rect x="16" y="12" width="38" height="5" rx="2" fill="#1e3a6a"/>
<rect x="16" y="19" width="28" height="5" rx="2" fill="#1e3a6a"/>
<rect x="16" y="26" width="20" height="5" rx="2" fill="#1e3a6a"/>
</svg>
),
},
{
mode: 'system', label: 'Système', desc: 'Suit les préférences OS',
preview: (
<svg viewBox="0 0 56 36" width="56" height="36" aria-hidden="true">
<defs>
<linearGradient id="split" x1="0" x2="1" y1="0" y2="0">
<stop offset="50%" stopColor="#f0f4ff"/>
<stop offset="50%" stopColor="#060e1f"/>
</linearGradient>
</defs>
<rect width="56" height="36" rx="5" fill="url(#split)"/>
<rect x="2" y="2" width="12" height="32" rx="3" fill="#1e3a8a"/>
<rect x="16" y="2" width="18" height="8" rx="2" fill="#ffffff" opacity=".9"/>
<rect x="36" y="2" width="18" height="8" rx="2" fill="#111c35" opacity=".9"/>
<rect x="16" y="12" width="18" height="5" rx="2" fill="#c7d2e8"/>
<rect x="36" y="12" width="18" height="5" rx="2" fill="#1e3a6a"/>
</svg>
),
},
];
const FONTS = [
{ scale: 'compact', label: 'Normal', desc: 'Interface compacte', sizes: { body: 12, table: 11 } },
{ scale: 'medium', label: 'Moyen', desc: 'Taille intermédiaire', sizes: { body: 13, table: 12 } },
{ scale: 'large', label: 'Grand', desc: 'Meilleure lisibilité', sizes: { body: 14, table: 13 } },
];
/* ── Icônes graphiques (couleurs) ────────────────────────────── */
const ICONS_BASE = '/api/icons-files/';
function AppIcon({ filename, size = 22 }) {
if (!filename) return null;
return (
<img
src={`${ICONS_BASE}${filename}`}
alt=""
width={size}
height={size}
className="app-lib-icon"
aria-hidden="true"
/>
);
}
/* ── Palette Material Design 2 ───────────────────────────────── */
const MD_PALETTE = {
'Rouge': { base: '#f44336', shades: { '50':'#ffebee','100':'#ffcdd2','200':'#ef9a9a','300':'#e57373','400':'#ef5350','500':'#f44336','600':'#e53935','700':'#d32f2f','800':'#c62828','900':'#b71c1c','A100':'#ff8a80','A200':'#ff5252','A400':'#ff1744','A700':'#d50000' } },
'Rose': { base: '#e91e63', shades: { '50':'#fce4ec','100':'#f8bbd0','200':'#f48fb1','300':'#f06292','400':'#ec407a','500':'#e91e63','600':'#d81b60','700':'#c2185b','800':'#ad1457','900':'#880e4f','A100':'#ff80ab','A200':'#ff4081','A400':'#f50057','A700':'#c51162' } },
'Violet': { base: '#9c27b0', shades: { '50':'#f3e5f5','100':'#e1bee7','200':'#ce93d8','300':'#ba68c8','400':'#ab47bc','500':'#9c27b0','600':'#8e24aa','700':'#7b1fa2','800':'#6a1b9a','900':'#4a148c','A100':'#ea80fc','A200':'#e040fb','A400':'#d500f9','A700':'#aa00ff' } },
'Violet foncé': { base: '#673ab7', shades: { '50':'#ede7f6','100':'#d1c4e9','200':'#b39ddb','300':'#9575cd','400':'#7e57c2','500':'#673ab7','600':'#5e35b1','700':'#512da8','800':'#4527a0','900':'#311b92','A100':'#b388ff','A200':'#7c4dff','A400':'#651fff','A700':'#6200ea' } },
'Indigo': { base: '#3f51b5', shades: { '50':'#e8eaf6','100':'#c5cae9','200':'#9fa8da','300':'#7986cb','400':'#5c6bc0','500':'#3f51b5','600':'#3949ab','700':'#303f9f','800':'#283593','900':'#1a237e','A100':'#8c9eff','A200':'#536dfe','A400':'#3d5afe','A700':'#304ffe' } },
'Bleu': { base: '#2196f3', shades: { '50':'#e3f2fd','100':'#bbdefb','200':'#90caf9','300':'#64b5f6','400':'#42a5f5','500':'#2196f3','600':'#1e88e5','700':'#1976d2','800':'#1565c0','900':'#0d47a1','A100':'#82b1ff','A200':'#448aff','A400':'#2979ff','A700':'#2962ff' } },
'Bleu clair': { base: '#03a9f4', shades: { '50':'#e1f5fe','100':'#b3e5fc','200':'#81d4fa','300':'#4fc3f7','400':'#29b6f6','500':'#03a9f4','600':'#039be5','700':'#0288d1','800':'#0277bd','900':'#01579b','A100':'#80d8ff','A200':'#40c4ff','A400':'#00b0ff','A700':'#0091ea' } },
'Cyan': { base: '#00bcd4', shades: { '50':'#e0f7fa','100':'#b2ebf2','200':'#80deea','300':'#4dd0e1','400':'#26c6da','500':'#00bcd4','600':'#00acc1','700':'#0097a7','800':'#00838f','900':'#006064','A100':'#84ffff','A200':'#18ffff','A400':'#00e5ff','A700':'#00b8d4' } },
'Sarcelle': { base: '#009688', shades: { '50':'#e0f2f1','100':'#b2dfdb','200':'#80cbc4','300':'#4db6ac','400':'#26a69a','500':'#009688','600':'#00897b','700':'#00796b','800':'#00695c','900':'#004d40','A100':'#a7ffeb','A200':'#64ffda','A400':'#1de9b6','A700':'#00bfa5' } },
'Vert': { base: '#4caf50', shades: { '50':'#e8f5e9','100':'#c8e6c9','200':'#a5d6a7','300':'#81c784','400':'#66bb6a','500':'#4caf50','600':'#43a047','700':'#388e3c','800':'#2e7d32','900':'#1b5e20','A100':'#b9f6ca','A200':'#69f0ae','A400':'#00e676','A700':'#00c853' } },
'Vert clair': { base: '#8bc34a', shades: { '50':'#f1f8e9','100':'#dcedc8','200':'#c5e1a5','300':'#aed581','400':'#9ccc65','500':'#8bc34a','600':'#7cb342','700':'#689f38','800':'#558b2f','900':'#33691e','A100':'#ccff90','A200':'#b2ff59','A400':'#76ff03','A700':'#64dd17' } },
'Citron vert': { base: '#cddc39', shades: { '50':'#f9fbe7','100':'#f0f4c3','200':'#e6ee9c','300':'#dce775','400':'#d4e157','500':'#cddc39','600':'#c0ca33','700':'#afb42b','800':'#9e9d24','900':'#827717','A100':'#f4ff81','A200':'#eeff41','A400':'#c6ff00','A700':'#aeea00' } },
'Jaune': { base: '#ffeb3b', shades: { '50':'#fffde7','100':'#fff9c4','200':'#fff59d','300':'#fff176','400':'#ffee58','500':'#ffeb3b','600':'#fdd835','700':'#fbc02d','800':'#f9a825','900':'#f57f17','A100':'#ffff8d','A200':'#ffff00','A400':'#ffea00','A700':'#ffd600' } },
'Ambre': { base: '#ffc107', shades: { '50':'#fff8e1','100':'#ffecb3','200':'#ffe082','300':'#ffd54f','400':'#ffca28','500':'#ffc107','600':'#ffb300','700':'#ffa000','800':'#ff8f00','900':'#ff6f00','A100':'#ffe57f','A200':'#ffd740','A400':'#ffc400','A700':'#ffab00' } },
'Orange': { base: '#ff9800', shades: { '50':'#fff3e0','100':'#ffe0b2','200':'#ffcc80','300':'#ffb74d','400':'#ffa726','500':'#ff9800','600':'#fb8c00','700':'#f57c00','800':'#ef6c00','900':'#e65100','A100':'#ffd180','A200':'#ffab40','A400':'#ff9100','A700':'#ff6d00' } },
'Orange foncé': { base: '#ff5722', shades: { '50':'#fbe9e7','100':'#ffccbc','200':'#ffab91','300':'#ff8a65','400':'#ff7043','500':'#ff5722','600':'#f4511e','700':'#e64a19','800':'#d84315','900':'#bf360c','A100':'#ff9e80','A200':'#ff6e40','A400':'#ff3d00','A700':'#dd2c00' } },
'Marron': { base: '#795548', shades: { '50':'#efebe9','100':'#d7ccc8','200':'#bcaaa4','300':'#a1887f','400':'#8d6e63','500':'#795548','600':'#6d4c41','700':'#5d4037','800':'#4e342e','900':'#3e2723' } },
'Gris': { base: '#9e9e9e', shades: { '50':'#fafafa','100':'#f5f5f5','200':'#eeeeee','300':'#e0e0e0','400':'#bdbdbd','500':'#9e9e9e','600':'#757575','700':'#616161','800':'#424242','900':'#212121' } },
'Gris bleu': { base: '#607d8b', shades: { '50':'#eceff1','100':'#cfd8dc','200':'#b0bec5','300':'#90a4ae','400':'#78909c','500':'#607d8b','600':'#546e7a','700':'#455a64','800':'#37474f','900':'#263238' } },
};
/* Retourne true si la couleur hex est claire (pour adapter la couleur du texte) */
function isLightColor(hex) {
const r = parseInt(hex.slice(1,3),16);
const g = parseInt(hex.slice(3,5),16);
const b = parseInt(hex.slice(5,7),16);
return (r * 299 + g * 587 + b * 114) / 1000 > 155;
}
/* Retrouve la famille et la teinte d'un hex donné */
function findInPalette(hex) {
const h = hex.toLowerCase();
for (const [family, { shades }] of Object.entries(MD_PALETTE)) {
for (const [shade, color] of Object.entries(shades)) {
if (color.toLowerCase() === h) return { family, shade };
}
}
return null;
}
/* Mélange une couleur hex avec du blanc (factor 0=original, 1=blanc) */
function lightenColor(hex, factor = 0.72) {
const r = parseInt(hex.slice(1,3),16);
const g = parseInt(hex.slice(3,5),16);
const b = parseInt(hex.slice(5,7),16);
const lr = Math.round(r + (255 - r) * factor);
const lg = Math.round(g + (255 - g) * factor);
const lb = Math.round(b + (255 - b) * factor);
return '#' + [lr,lg,lb].map(v => v.toString(16).padStart(2,'0')).join('');
}
/* ── Palettes suggérées ──────────────────────────────────────── */
const SUGGESTED_PALETTES = [
{ name: 'Classique', interets: '#2196f3', capital: '#4caf50', cashback: '#ffc107' },
{ name: 'Indigo & Teal', interets: '#3949ab', capital: '#009688', cashback: '#fb8c00' },
{ name: 'Nuit', interets: '#5c6bc0', capital: '#4dd0e1', cashback: '#ffca28' },
{ name: 'Nature', interets: '#43a047', capital: '#039be5', cashback: '#ff9800' },
{ name: 'Coucher de soleil', interets: '#ff5722', capital: '#1976d2', cashback: '#ffa000' },
{ name: 'Violet', interets: '#7e57c2', capital: '#26a69a', cashback: '#ffb300' },
{ name: 'Frais', interets: '#00acc1', capital: '#7cb342', cashback: '#ec407a' },
{ name: 'Contraste', interets: '#e53935', capital: '#1e88e5', cashback: '#43a047' },
{ name: 'Pastel', interets: '#29b6f6', capital: '#66bb6a', cashback: '#ffb74d' },
{ name: 'Moderne', interets: '#9c27b0', capital: '#00bcd4', cashback: '#ff9800' },
];
function PaletteSelector({ interets, capital, cashback, onSelect }) {
const isActive = (p) =>
p.interets === interets && p.capital === capital && p.cashback === cashback;
return (
<div style={{ marginBottom: 24 }}>
<p style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)', fontWeight: 600, color: 'var(--text)' }}>
Palettes suggérées
</p>
<div className="palette-grid">
{SUGGESTED_PALETTES.map((p) => (
<button
key={p.name}
type="button"
className={`palette-card${isActive(p) ? ' active' : ''}`}
onClick={() => onSelect(p)}
title={p.name}
>
<div className="palette-swatches">
<span className="palette-swatch" style={{ backgroundColor: p.interets }} />
<span className="palette-swatch" style={{ backgroundColor: p.capital }} />
<span className="palette-swatch" style={{ backgroundColor: p.cashback }} />
</div>
<span className="palette-name">{p.name}</span>
</button>
))}
</div>
</div>
);
}
function ChartColorPicker({ value, onChange }) {
const found = findInPalette(value);
const [selectedFamily, setSelectedFamily] = useState(found?.family || 'Bleu');
// Sync la famille quand value change depuis l'extérieur (sélection palette)
useEffect(() => {
const f = findInPalette(value);
if (f) setSelectedFamily(f.family);
}, [value]);
const familyData = MD_PALETTE[selectedFamily];
const shadeEntries = familyData ? Object.entries(familyData.shades) : [];
function handleFamilyChange(e) {
const fam = e.target.value;
setSelectedFamily(fam);
// Auto-select shade 500 (or first available) when changing family
const shades = MD_PALETTE[fam]?.shades || {};
const target = shades['500'] || Object.values(shades)[5] || Object.values(shades)[0];
if (target) onChange(target);
}
const textColor = isLightColor(value) ? '#212121' : '#ffffff';
return (
<div className="chart-color-picker">
<div className="color-family-select-row">
<span className="color-family-indicator" style={{ backgroundColor: familyData?.base || value }} />
<select value={selectedFamily} onChange={handleFamilyChange}>
{Object.keys(MD_PALETTE).map(name => (
<option key={name} value={name}>{name}</option>
))}
</select>
</div>
<div className="color-shade-grid">
{shadeEntries.map(([shade, hex]) => (
<button
key={shade}
type="button"
title={`${selectedFamily} ${shade}${hex}`}
className={`color-shade-btn${value.toLowerCase() === hex.toLowerCase() ? ' selected' : ''}`}
style={{ backgroundColor: hex }}
onClick={() => onChange(hex)}
>
<span className="color-shade-label" style={{ color: isLightColor(hex) ? '#000' : '#fff' }}>
{shade}
</span>
</button>
))}
</div>
<div className="color-preview-pair">
<div className="color-preview-bar" style={{ backgroundColor: value, color: textColor }}>
<span className="color-preview-label">Reçu</span>
<span className="color-preview-hex">{value.toUpperCase()}</span>
</div>
<div className="color-preview-bar" style={{ backgroundColor: lightenColor(value), color: isLightColor(lightenColor(value)) ? '#333' : '#fff' }}>
<span className="color-preview-label">Projeté</span>
<span className="color-preview-hex">{lightenColor(value).toUpperCase()}</span>
</div>
</div>
</div>
);
}
export default function AppearanceSection() {
const { mode, setMode } = useTheme();
const { fontScale, setFontScale, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback } = useUi();
const [libIcons, setLibIcons] = useState({});
useEffect(() => {
api.get('/icons').then(rows => {
const m = {};
rows.forEach(r => { m[r.name] = r.filename; });
setLibIcons(m);
}).catch(() => {});
}, []);
return (
<>
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Thème</h3>
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
Choisissez le thème visuel de l'application.
</p>
<div className="pref-options">
{THEMES.map((t) => (
<button key={t.mode} type="button"
className={`pref-option${mode === t.mode ? ' active' : ''}`}
onClick={() => setMode(t.mode)} aria-pressed={mode === t.mode}>
{t.preview}
<span style={{ fontWeight: 700, fontSize: 'var(--fs-sm)', marginTop: 4 }}>{t.label}</span>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>{t.desc}</span>
</button>
))}
</div>
</div>
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Taille du texte</h3>
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
Le niveau <strong>Grand</strong> est recommandé pour les personnes malvoyantes.
Les niveaux inférieurs permettent d'afficher plus de données à l'écran.
</p>
<div className="pref-options">
{FONTS.map((f) => (
<button key={f.scale} type="button"
className={`pref-option${fontScale === f.scale ? ' active' : ''}`}
onClick={() => setFontScale(f.scale)} aria-pressed={fontScale === f.scale}>
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4,
width: '100%', padding: '8px 0 4px',
borderBottom: '1px solid var(--border)', marginBottom: 4,
}}>
<span className="font-preview" style={{ fontSize: f.sizes.body }}>Aa — {f.label}</span>
<span style={{ fontSize: f.sizes.table, color: 'var(--text-muted)' }}>
Tableau {f.sizes.table}px · Corps {f.sizes.body}px
</span>
</div>
<span style={{ fontWeight: 700, fontSize: 'var(--fs-sm)', marginTop: 2 }}>{f.label}</span>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>{f.desc}</span>
</button>
))}
</div>
</div>
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Couleurs des graphiques</h3>
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
Choisissez une palette ci-dessous ou personnalisez chaque série individuellement.
</p>
<PaletteSelector
interets={chartInterets}
capital={chartCapital}
cashback={chartCashback}
onSelect={(p) => { setChartInterets(p.interets); setChartCapital(p.capital); setChartCashback(p.cashback); }}
/>
<div className="chart-color-row">
<div className="chart-color-item">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '1.1rem', fontWeight: 600 }}>
<AppIcon filename={libIcons.interets} size={33} />
Intérêts
</label>
<ChartColorPicker value={chartInterets} onChange={setChartInterets} />
</div>
<div className="chart-color-item">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '1.1rem', fontWeight: 600 }}>
<AppIcon filename={libIcons.capital} size={33} />
Capital
</label>
<ChartColorPicker value={chartCapital} onChange={setChartCapital} />
</div>
<div className="chart-color-item">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '1.1rem', fontWeight: 600 }}>
<AppIcon filename={libIcons.cashback} size={33} />
Cashback
</label>
<ChartColorPicker value={chartCashback} onChange={setChartCashback} />
</div>
</div>
</div>
</>
);
}
/* ── Icônes nav ──────────────────────────────────────────────── */
@@ -0,0 +1,197 @@
import { useState, useEffect } from 'react';
import { api } from '../../api.js';
export default function CategoriesInvSection() {
const [categoriesInv, setCategoriesInv] = useState([]);
const [err, setErr] = useState(null);
const [selectedCatInv, setSelectedCatInv] = useState(null);
const [editingCatInv, setEditingCatInv] = useState(null);
const [editingNomCatInv, setEditingNomCatInv] = useState('');
const [newCatInvNom, setNewCatInvNom] = useState('');
const [showNewCatInv, setShowNewCatInv] = useState(false);
const [catGlobalOpen, setCatGlobalOpen] = useState(false);
const [catPrivateOpen, setCatPrivateOpen] = useState(true);
useEffect(() => {
api.get('/categories-inv').then(data => {
setCategoriesInv(data.sort((a, b) => b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
}).catch(() => {});
}, []);
const saveCatInv = async (nom) => {
if (!nom.trim()) return;
try {
const row = await api.post('/categories-inv', { nom: nom.trim() });
setCategoriesInv(prev => [...prev, row].sort((a, b) =>
b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
setShowNewCatInv(false); setNewCatInvNom(''); setErr(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const renameCatInv = async (id, nom) => {
if (!nom.trim()) return;
try {
await api.put(`/categories-inv/${id}`, { nom: nom.trim() });
setCategoriesInv(prev => prev.map(c => c.id === id ? { ...c, nom: nom.trim() } : c));
setEditingCatInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const delCatInv = async (id) => {
try {
await api.del(`/categories-inv/${id}`);
setCategoriesInv(prev => prev.filter(c => c.id !== id));
if (selectedCatInv?.id === id) setSelectedCatInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const globalCats = categoriesInv.filter(c => c.is_global);
const privateCats = categoriesInv.filter(c => !c.is_global);
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<h3 style={{ margin: 0 }}>Mes catégories d'investissement</h3>
</div>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
{/* Accordéon — Catégories globalement définies */}
<div className="card" style={{ marginBottom: 10 }}>
<button type="button"
onClick={() => setCatGlobalOpen(o => !o)}
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--text)' }}>
<span style={{ fontWeight: 600, fontSize: 'var(--fs-base)' }}>
Catégories globalement définies
<span style={{ marginLeft: 8, fontWeight: 400, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
({globalCats.length})
</span>
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
style={{ transform: catGlobalOpen ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{catGlobalOpen && (
<div style={{ marginTop: 14 }}>
<table>
<thead>
<tr>
<th>Nom</th>
<th className="num">Plateformes</th>
<th className="num">Investissements</th>
</tr>
</thead>
<tbody>
{globalCats.length === 0 && (
<tr><td colSpan={3} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune catégorie globale</td></tr>
)}
{globalCats.map(c => (
<tr key={c.id}>
<td><span style={{ fontWeight: 600 }}>{c.nom}</span></td>
<td className="num">{c.nb_plateformes > 0 ? c.nb_plateformes : <span className="text-muted">—</span>}</td>
<td className="num">{c.nb_investissements > 0 ? c.nb_investissements : <span className="text-muted">—</span>}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Accordéon — Mes propres catégories */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<button type="button"
onClick={() => setCatPrivateOpen(o => !o)}
style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 8,
background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--text)', textAlign: 'left' }}>
<span style={{ fontWeight: 600, fontSize: 'var(--fs-base)' }}>
Mes propres catégories
<span style={{ marginLeft: 8, fontWeight: 400, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
({privateCats.length})
</span>
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
style={{ transform: catPrivateOpen ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{catPrivateOpen && (
<button className="primary" type="button" style={{ marginLeft: 12, flexShrink: 0 }}
onClick={() => { setCatPrivateOpen(true); setShowNewCatInv(true); setNewCatInvNom(''); setErr(null); }}>
+ Ajouter
</button>
)}
</div>
{catPrivateOpen && (
<div style={{ marginTop: 14 }}>
{showNewCatInv && (
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<input autoFocus style={{ flex: 1 }} placeholder="Nom de la catégorie"
value={newCatInvNom} onChange={e => setNewCatInvNom(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') saveCatInv(newCatInvNom); if (e.key === 'Escape') setShowNewCatInv(false); }} />
<button className="primary" onClick={() => saveCatInv(newCatInvNom)}>Créer</button>
<button onClick={() => setShowNewCatInv(false)}>Annuler</button>
</div>
)}
<table>
<thead>
<tr>
<th>Nom</th>
<th className="num">Plateformes</th>
<th className="num">Investissements</th>
<th style={{ width: 80 }}></th>
</tr>
</thead>
<tbody>
{privateCats.length === 0 && (
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune catégorie personnelle</td></tr>
)}
{privateCats.map(c => (
<tr key={c.id}>
<td>
{editingCatInv === c.id ? (
<div style={{ display: 'flex', gap: 6 }}>
<input autoFocus style={{ flex: 1 }} value={editingNomCatInv}
onChange={e => setEditingNomCatInv(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') renameCatInv(c.id, editingNomCatInv); if (e.key === 'Escape') setEditingCatInv(null); }} />
<button className="primary" onClick={() => renameCatInv(c.id, editingNomCatInv)}>OK</button>
<button onClick={() => setEditingCatInv(null)}>✕</button>
</div>
) : (
<span style={{ fontWeight: 600 }}>{c.nom}</span>
)}
</td>
<td className="num">{c.nb_plateformes > 0 ? c.nb_plateformes : <span className="text-muted">—</span>}</td>
<td className="num">{c.nb_investissements > 0 ? c.nb_investissements : <span className="text-muted">—</span>}</td>
<td>
{editingCatInv !== c.id && (
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
<button style={{ fontSize: 12, padding: '2px 8px' }}
onClick={() => { setEditingCatInv(c.id); setEditingNomCatInv(c.nom); setErr(null); }}>
Renommer
</button>
<button style={{ fontSize: 12, padding: '2px 8px', color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => setConfirmDelete({
title: 'Supprimer la catégorie',
message: `Supprimer la catégorie "${c.nom}" ? Elle sera retirée de toutes les plateformes et investissements.`,
confirmLabel: 'Supprimer',
onConfirm: () => { delCatInv(c.id); setConfirmDelete(null); }
})}>
Supprimer
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,250 @@
import { useState, useEffect, useCallback } from 'react';
import { api } from '../../api.js';
import { memberLabel } from '../../utils/format.js';
import ConfirmModal from '../../components/ConfirmModal.jsx';
import Modal from '../../components/Modal.jsx';
const EXONERATION_LABELS = {
aucune: 'Aucune',
pfnl_5ans: 'PFnl 5 ans',
};
const TYPE_COMPTE_LABELS = {
compte_courant: 'Compte courant',
pea_pme: 'PEA-PME',
};
const EMPTY_COMPTE = { nom: '', type: 'compte_courant', investisseur_id: null, banque: '', exoneration_fiscale: 'aucune' };
function compteInvestisseur(c) {
if (!c.investisseur_id) return null;
return { id: c.investisseur_id, nom: c.investisseur_nom, prenom: c.investisseur_prenom, type: c.investisseur_type, type_fiscal: c.investisseur_type_fiscal };
}
function CompteFormFields({ state, setter, investisseurs }) {
return (
<>
<div>
<label>Nom *</label>
<input required value={state.nom}
onChange={e => setter({ ...state, nom: e.target.value })}
placeholder="ex. Compte courant BNP" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label>Type *</label>
<select value={state.type} onChange={e => setter({ ...state, type: e.target.value })}>
<option value="compte_courant">Compte courant</option>
<option value="pea_pme">PEA-PME</option>
</select>
</div>
<div>
<label>Banque</label>
<input value={state.banque}
onChange={e => setter({ ...state, banque: e.target.value })}
placeholder="ex. BNP Paribas" />
</div>
</div>
<div>
<label>Détenteur</label>
<select value={state.investisseur_id ?? ''}
onChange={e => setter({ ...state, investisseur_id: e.target.value ? Number(e.target.value) : null })}>
<option value=""> Non renseigné </option>
{investisseurs.map(inv => (
<option key={inv.id} value={inv.id}>{memberLabel(inv)}</option>
))}
</select>
</div>
<div>
<label>Exonération fiscale</label>
<select value={state.exoneration_fiscale ?? 'aucune'}
onChange={e => setter({ ...state, exoneration_fiscale: e.target.value })}>
<option value="aucune">Aucune exonération fiscale</option>
<option value="pfnl_5ans">Exonération Impôts sur le revenu (PFNL) si détention 5 ans</option>
</select>
</div>
</>
);
}
export default function ComptesSection() {
const [comptes, setComptes] = useState([]);
const [investisseurs, setInvestisseurs] = useState([]);
const reload = useCallback(async () => {
const [cpts, invs] = await Promise.all([
api.get('/comptes'),
api.get('/investisseurs'),
]);
setComptes(cpts);
setInvestisseurs(invs);
}, []);
useEffect(() => { reload(); }, [reload]);
const [showNew, setShowNew] = useState(false);
const [newCompte, setNewCompte] = useState(EMPTY_COMPTE);
const [editCompte, setEditCompte] = useState(null);
const [err, setErr] = useState(null);
const [confirmDel, setConfirmDel] = useState(null);
const openNew = () => { setNewCompte(EMPTY_COMPTE); setErr(null); setShowNew(true); };
const addCompte = async (e) => {
e.preventDefault(); setErr(null);
try {
await api.post('/comptes', {
nom: newCompte.nom,
type: newCompte.type,
banque: newCompte.banque || null,
investisseur_id: newCompte.investisseur_id ? Number(newCompte.investisseur_id) : null,
exoneration_fiscale: newCompte.exoneration_fiscale ?? 'aucune',
});
setShowNew(false); setNewCompte(EMPTY_COMPTE); reload();
} catch (ex) { setErr(ex.message); }
};
const saveEdit = async (e) => {
e.preventDefault(); setErr(null);
try {
await api.put(`/comptes/${editCompte.id}`, {
nom: editCompte.nom,
type: editCompte.type,
banque: editCompte.banque || null,
investisseur_id: editCompte.investisseur_id ? Number(editCompte.investisseur_id) : null,
exoneration_fiscale: editCompte.exoneration_fiscale ?? 'aucune',
});
setEditCompte(null); reload();
} catch (ex) { setErr(ex.message); }
};
const openEdit = (c) => {
setErr(null);
setEditCompte({ id: c.id, nom: c.nom, type: c.type, banque: c.banque || '', investisseur_id: c.investisseur_id ?? null, exoneration_fiscale: c.exoneration_fiscale ?? 'aucune' });
};
const del = (c) => {
setConfirmDel({
message: `Supprimer le compte "${c.nom}" ?`,
onConfirm: async () => {
try { await api.del(`/comptes/${c.id}`); reload(); }
catch (ex) { setErr(ex.message); }
finally { setConfirmDel(null); }
},
});
};
return (
<>
<div className="card">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 16 }}>
<div>
<h3 style={{ margin: 0 }}>Mes comptes courants</h3>
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: '4px 0 0' }}>
Comptes bancaires et enveloppes financières associés à vos investisseurs.
</p>
</div>
<button className="primary" style={{ whiteSpace: 'nowrap' }} onClick={openNew}>
+ Nouveau compte
</button>
</div>
{err && <div className="error" style={{ marginBottom: 10 }}>{err}</div>}
<table>
<thead>
<tr>
<th>Nom</th>
<th style={{ width: '14%' }}>Type</th>
<th style={{ width: '22%' }}>Détenteur</th>
<th style={{ width: '16%' }}>Banque</th>
<th style={{ width: '10%' }}>Exonération</th>
<th style={{ width: 80 }} />
</tr>
</thead>
<tbody>
{comptes.length === 0 && (
<tr><td colSpan={6} className="text-muted" style={{ textAlign: 'center', fontStyle: 'italic' }}>
Aucun compte défini.
</td></tr>
)}
{comptes.map(c => {
const inv = compteInvestisseur(c);
return (
<tr key={c.id}>
<td style={{ fontWeight: 500 }}>{c.nom}</td>
<td><span className="badge">{TYPE_COMPTE_LABELS[c.type] ?? c.type}</span></td>
<td>{inv ? memberLabel(inv) : <span className="text-muted"></span>}</td>
<td>{c.banque || <span className="text-muted"></span>}</td>
<td>
{c.exoneration_fiscale && c.exoneration_fiscale !== 'aucune' ? (
<span
title={EXONERATION_LABELS[c.exoneration_fiscale] ?? c.exoneration_fiscale}
style={{ cursor: 'help', color: 'var(--success)', fontWeight: 600, fontSize: 'var(--fs-sm)' }}
>Oui</span>
) : (
<span className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>Non</span>
)}
</td>
<td>
<div style={{ display: 'flex', gap: 4 }}>
<button style={{ padding: '3px 10px', fontSize: 11 }} onClick={() => openEdit(c)}>Modifier</button>
<button className="danger" style={{ padding: '3px 10px', fontSize: 11 }} onClick={() => del(c)}></button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* ── Modal création ── */}
<Modal
open={showNew}
title="Nouveau compte"
onClose={() => { setShowNew(false); setNewCompte(EMPTY_COMPTE); setErr(null); }}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%', gap: 8 }}>
<button type="button" onClick={() => { setShowNew(false); setNewCompte(EMPTY_COMPTE); setErr(null); }}>Annuler</button>
<button className="primary" form="form-new-compte" type="submit">Créer</button>
</div>
}
>
<form id="form-new-compte" onSubmit={addCompte} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{err && <div className="error">{err}</div>}
<CompteFormFields state={newCompte} setter={setNewCompte} investisseurs={investisseurs} />
</form>
</Modal>
{/* ── Modal édition ── */}
<Modal
open={!!editCompte}
title="Modifier le compte"
onClose={() => { setEditCompte(null); setErr(null); }}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<button className="danger" type="button" onClick={() => { setEditCompte(null); del(comptes.find(c => c.id === editCompte?.id) ?? editCompte); }}>
Supprimer
</button>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" onClick={() => { setEditCompte(null); setErr(null); }}>Annuler</button>
<button className="primary" form="form-edit-compte" type="submit">Enregistrer</button>
</div>
</div>
}
>
<form id="form-edit-compte" onSubmit={saveEdit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{err && <div className="error">{err}</div>}
<CompteFormFields state={editCompte} setter={setEditCompte} investisseurs={investisseurs} />
</form>
</Modal>
{/* ── ConfirmModal suppression ── */}
<ConfirmModal
open={!!confirmDel}
message={confirmDel?.message}
onConfirm={confirmDel?.onConfirm}
onCancel={() => setConfirmDel(null)}
/>
</>
);
}
@@ -0,0 +1,304 @@
import { useState } from 'react';
import { api } from '../../api.js';
function IconBroom() {
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="M3 21l9-9"/><path d="M12.22 6.22L17 1.5l5.5 5.5-4.72 4.78"/><path d="M5 17c.5-2 2-3.5 4-4.5l3.5 3.5c-1 2-2.5 3.5-4.5 4"/></svg>;
}
export default function DataCleanupSection() {
const [showModal, setShowModal] = useState(false);
const [loading, setLoading] = useState(false);
const [showReprocessModal, setShowReprocessModal] = useState(false);
const [loadingReprocess, setLoadingReprocess] = useState(false);
const [showDiffereModal, setShowDiffereModal] = useState(false);
const [loadingDiffere, setLoadingDiffere] = useState(false);
const [showBackfillModal, setShowBackfillModal] = useState(false);
const [loadingBackfill, setLoadingBackfill] = useState(false);
const [successMsg, setSuccessMsg] = useState(null);
const [errorMsg, setErrorMsg] = useState(null);
const handleReprocess = async () => {
setLoadingReprocess(true);
setErrorMsg(null);
setSuccessMsg(null);
try {
const { updated } = await api.post('/remboursements/reprocess', {});
setSuccessMsg(`${updated} remboursement${updated > 1 ? 's' : ''} recalculé${updated > 1 ? 's' : ''} avec succès.`);
setShowReprocessModal(false);
} catch (err) {
setErrorMsg(err.message || 'Une erreur est survenue.');
setShowReprocessModal(false);
} finally {
setLoadingReprocess(false);
}
};
const handleReassign = async () => {
setLoading(true);
setErrorMsg(null);
setSuccessMsg(null);
try {
await api.post('/investisseurs/reassign-to-principal', {});
setSuccessMsg('Toutes les données ont été réaffectées au compte principal.');
setShowModal(false);
} catch (err) {
setErrorMsg(err.message || 'Une erreur est survenue.');
setShowModal(false);
} finally {
setLoading(false);
}
};
const handleFixDiffereDates = async () => {
setLoadingDiffere(true);
setErrorMsg(null);
setSuccessMsg(null);
try {
const { updated, detail } = await api.post('/investissements/fix-differe-dates', {});
if (updated === 0) {
setSuccessMsg('Aucune date incohérente détectée sur les prêts différés.');
} else {
setSuccessMsg(
`${updated} prêt${updated > 1 ? 's' : ''} différé${updated > 1 ? 's' : ''} corrigé${updated > 1 ? 's' : ''} : ` +
detail.map(d => d.nom_projet).join(', ') + '.'
);
}
setShowDiffereModal(false);
} catch (err) {
setErrorMsg(err.message || 'Une erreur est survenue.');
setShowDiffereModal(false);
} finally {
setLoadingDiffere(false);
}
};
const handleBackfillComptes = async () => {
setLoadingBackfill(true);
setErrorMsg(null);
setSuccessMsg(null);
try {
const { updated, total } = await api.post('/remboursements/backfill-comptes', {});
if (updated === 0) {
setSuccessMsg(`Aucun remboursement à corriger (${total} vérifié${total > 1 ? 's' : ''}).`);
} else {
setSuccessMsg(`${updated} remboursement${updated > 1 ? 's' : ''} mis à jour sur ${total} vérifié${total > 1 ? 's' : ''}.`);
}
setShowBackfillModal(false);
} catch (err) {
setErrorMsg(err.message || 'Une erreur est survenue.');
setShowBackfillModal(false);
} finally {
setLoadingBackfill(false);
}
};
return (
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Nettoyage de données</h3>
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
Opérations de maintenance sur les données du compte.
</p>
{errorMsg && <div className="error" style={{ marginBottom: 12 }}>{errorMsg}</div>}
{successMsg && <div className="success-msg" style={{ marginBottom: 12 }}>{successMsg}</div>}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderRadius: 8,
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))',
marginBottom: 10 }}>
<div>
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
Recalculer les champs fiscaux des remboursements
</div>
<div className="text-muted" style={{ fontSize: 12 }}>
Recalcule prélèvements sociaux, impôt sur le revenu, intérêts nets et net reçu de tous
vos remboursements selon la fiscalité de chaque plateforme et les taux PFU de l'année.
Met également à jour les retraits automatiques associés.
</div>
</div>
<button
style={{ marginLeft: 16, whiteSpace: 'nowrap', flexShrink: 0 }}
onClick={() => setShowReprocessModal(true)}
disabled={loadingReprocess}
>
Recalculer
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderRadius: 8,
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))',
marginBottom: 10 }}>
<div>
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
Corriger les dates des prêts différés
</div>
<div className="text-muted" style={{ fontSize: 12 }}>
Recalcule la date de 1ère échéance et la date cible à partir de la date de souscription
et de la durée prévue. Seuls les prêts dont les dates s'écartent de plus de 2 ans
de la valeur calculée sont corrigés.
</div>
</div>
<button
style={{ marginLeft: 16, whiteSpace: 'nowrap', flexShrink: 0 }}
onClick={() => setShowDiffereModal(true)}
disabled={loadingDiffere}
>
Corriger
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderRadius: 8,
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))',
marginBottom: 10 }}>
<div>
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
Lier les remboursements aux comptes courants
</div>
<div className="text-muted" style={{ fontSize: 12 }}>
Pour chaque remboursement en mode "Compte courant" sans compte lié, associe automatiquement
le compte de l'investissement ou le premier compte courant du détenteur.
Les remboursements déjà liés ou redirigés vers le porte-monnaie ne sont pas touchés.
</div>
</div>
<button
style={{ marginLeft: 16, whiteSpace: 'nowrap', flexShrink: 0 }}
onClick={() => setShowBackfillModal(true)}
disabled={loadingBackfill}
>
Lier
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderRadius: 8,
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))' }}>
<div>
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
Réaffecter l'ensemble des données au compte principal
</div>
<div className="text-muted" style={{ fontSize: 12 }}>
Investissements, dépôts/retraits tous les enregistrements seront attribués au titulaire principal.
</div>
</div>
<button
className="danger"
style={{ marginLeft: 16, whiteSpace: 'nowrap', flexShrink: 0 }}
onClick={() => setShowModal(true)}
disabled={loading}
>
Réaffecter
</button>
</div>
{showDiffereModal && (
<div className="modal-overlay" onClick={() => setShowDiffereModal(false)}>
<div className="modal" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 16 }}>
<h3 style={{ margin: 0 }}>Corriger les dates des prêts différés</h3>
</div>
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }}>
Pour chaque prêt de type <strong>différé</strong>, la date cible et la date de 1ère échéance
seront recalculées comme suit :
</p>
<p style={{ margin: '0 0 12px', lineHeight: 1.6, fontFamily: 'monospace', fontSize: 13,
background: 'var(--surface-2, var(--bg))', padding: '8px 12px', borderRadius: 6,
border: '1px solid var(--border)' }}>
date souscription + durée (mois)
</p>
<p style={{ margin: '0 0 20px', lineHeight: 1.6 }} className="text-muted">
La correction ne s'applique que si l'écart entre la date existante et la date calculée
dépasse <strong>2 ans</strong>. Les simulations de remboursement associées ne sont pas
recalculées automatiquement.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => setShowDiffereModal(false)} disabled={loadingDiffere}>Annuler</button>
<button className="primary" onClick={() => handleFixDiffereDates()} disabled={loadingDiffere}>
{loadingDiffere ? 'Correction en cours…' : 'Confirmer la correction'}
</button>
</div>
</div>
</div>
)}
{showBackfillModal && (
<div className="modal-overlay" onClick={() => setShowBackfillModal(false)}>
<div className="modal" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 16 }}>
<h3 style={{ margin: 0 }}>Lier les remboursements aux comptes courants</h3>
</div>
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }}>
Pour chaque remboursement en mode <strong>"Compte courant de l'investisseur"</strong> sans compte lié, cette opération va :
</p>
<ul style={{ margin: '0 0 12px', paddingLeft: 20, lineHeight: 1.8, fontSize: 'var(--fs-sm)' }}>
<li>Utiliser le compte défini sur l'investissement lié, si disponible</li>
<li>Sinon, prendre le premier compte courant du détenteur</li>
</ul>
<p style={{ margin: '0 0 20px', lineHeight: 1.6 }} className="text-muted">
Les remboursements déjà liés à un compte ou redirigés vers le porte-monnaie ne sont pas modifiés.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => setShowBackfillModal(false)} disabled={loadingBackfill}>Annuler</button>
<button className="primary" onClick={handleBackfillComptes} disabled={loadingBackfill}>
{loadingBackfill ? 'Traitement en cours' : 'Confirmer'}
</button>
</div>
</div>
</div>
)}
{showReprocessModal && (
<div className="modal-overlay" onClick={() => setShowReprocessModal(false)}>
<div className="modal" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 16 }}>
<h3 style={{ margin: 0 }}>Recalculer les remboursements</h3>
</div>
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }}>
Cette opération va recalculer pour <strong>tous vos remboursements</strong> les champs suivants :
</p>
<ul style={{ margin: '0 0 12px', paddingLeft: 20, lineHeight: 1.8, fontSize: 'var(--fs-sm)' }}>
<li>Prélèvements sociaux et impôt sur le revenu (taux PFU de l'année)</li>
<li>Taxe locale (pour les plateformes avec fiscalité locale)</li>
<li>Intérêts nets et montant net reçu</li>
<li>Montant des retraits automatiques associés</li>
</ul>
<p style={{ margin: '0 0 20px', lineHeight: 1.6 }} className="text-muted">
Les valeurs saisies manuellement seront écrasées. Cette opération ne peut pas être annulée.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => setShowReprocessModal(false)} disabled={loadingReprocess}>Annuler</button>
<button className="primary" onClick={handleReprocess} disabled={loadingReprocess}>
{loadingReprocess ? 'Recalcul en cours…' : 'Confirmer le recalcul'}
</button>
</div>
</div>
</div>
)}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal" style={{ maxWidth: 440 }} onClick={e => e.stopPropagation()}>
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 16 }}>
<h3 style={{ margin: 0, color: 'var(--danger, #ef4444)' }}> Action irréversible</h3>
</div>
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }}>
Cette action va réaffecter <strong>l'ensemble de vos investissements et mouvements financiers</strong> au compte principal.
</p>
<p style={{ margin: '0 0 20px', lineHeight: 1.6 }} className="text-muted">
Les données actuellement rattachées à d'autres membres ou entreprises seront transférées au titulaire principal. Cette opération ne peut pas être annulée.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => setShowModal(false)} disabled={loading}>Annuler</button>
<button className="danger" onClick={handleReassign} disabled={loading}>
{loading ? 'Réaffectation…' : 'Confirmer la réaffectation'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,499 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../../api.js';
import { fmtDate } from '../../utils/format.js';
import { useInvestisseur } from '../../context/InvestisseurContext.jsx';
import ResultBanner from '../../components/ResultBanner.jsx';
function dlBlob(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code ?? '—';
const FISCALITE_LABELS = {
flat_tax: 'Flat Tax',
sans_fiscalite_locale: 'Sans fiscalité locale',
avec_fiscalite_locale: 'Avec fiscalité locale',
};
const METHODE_REMB_LABELS = {
portefeuille: 'Porte-monnaie de la plateforme',
compte_courant: "Compte courant de l'investisseur",
choix_investisseur: "Au choix de l'investisseur (sur la plateforme)",
};
/** Reconstruit un objet investisseur minimal depuis les colonnes dénormalisées de la plateforme */
function IconCSV() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="10" y1="9" x2="14" y2="9"/></svg>;
}
function IconXLS() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l2 2 4-4"/></svg>;
}
function IconJSON() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M8 13h1.5a1.5 1.5 0 0 1 0 3H8v-3z"/><path d="M14 13h2v1.5a1.5 1.5 0 0 1-3 0V13z"/></svg>;
}
function IconImport() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 14 12 9 17 14"/><line x1="12" y1="9" x2="12" y2="21"/></svg>;
}
/* ── ExportDropdown ──────────────────────────────────────────── */
function ExportDropdown({ disabled, onCSV, onXLS, onJSON, title = 'Exporter' }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const handler = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const choose = (fn) => { setOpen(false); fn(); };
return (
<div ref={ref} style={{ position: 'relative' }}>
<button type="button" className="icon-btn" disabled={disabled}
onClick={() => setOpen(o => !o)} title={title}
aria-haspopup="menu" aria-expanded={open}>
<IconExport />
</button>
{open && (
<div className="export-dropdown" role="menu">
<button role="menuitem" onClick={() => choose(onCSV)}>
<IconCSV /><span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
</button>
<button role="menuitem" onClick={() => choose(onXLS)}>
<IconXLS /><span><strong>Format Excel</strong><small>Fichier .xls natif Microsoft</small></span>
</button>
{onJSON && (
<button role="menuitem" onClick={() => choose(onJSON)}>
<IconJSON /><span><strong>Format JSON</strong><small>Données structurées</small></span>
</button>
)}
</div>
)}
</div>
);
}
/* ── Helpers PFU ─────────────────────────────────────────────── */
function IconUpload() {
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="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>;
}
/* ── Imports — constantes ────────────────────────────────────── */
const MODULES = {
depots_retraits: {
label: 'Dépôts / Retraits',
required: ['date_operation', 'type', 'montant'],
optional: ['plateforme_id', 'libelle', 'reference'],
needsInvestisseur: true,
},
investissements: {
label: 'Investissements',
required: ['nom_projet', 'date_souscription', 'montant_investi'],
optional: ['plateforme_id', 'emetteur', 'date_premiere_echeance', 'date_cible', 'taux_interet', 'duree_mois', 'type_remb', 'freq_interets', 'statut', 'reference'],
needsInvestisseur: true,
},
remboursements: {
label: 'Remboursements',
required: ['investissement_id', 'date_remb'],
optional: ['capital', 'interets_bruts', 'prelev_sociaux', 'prelev_forfaitaire', 'net_recu', 'statut'],
needsInvestisseur: true,
},
plateformes: {
label: 'Plateformes',
required: ['nom'],
optional: ['url', 'notes'],
needsInvestisseur: false,
note: 'Les plateformes dont le nom existe déjà seront ignorées (pas d\'écrasement).',
},
taux_pfu: {
label: 'Flat Tax — Taux PFU',
required: ['annee', 'pfu_total', 'impot_revenu', 'prelev_sociaux'],
optional: [],
needsInvestisseur: false,
global: true,
note: 'Table de référence globale. Si une année existe déjà, ses taux seront mis à jour (upsert).',
},
};
const MODULE_LABEL = {
depots_retraits: 'Dépôts / Retraits',
investissements: 'Investissements',
remboursements: 'Remboursements',
plateformes: 'Plateformes',
taux_pfu: 'Flat Tax — Taux PFU',
};
/* ── Imports — composant dossier ─────────────────────────────── */
function DossierImport({
activeId, navigate,
dossierFile, setDossierFile,
dossierPreview, setDossierPreview,
dossierResult, setDossierResult,
dossierBusy, setDossierBusy,
dossierErr, setDossierErr,
dossierInputRef, reloadHistory,
}) {
const missingInv = !activeId;
const onFileChange = (e) => {
const f = e.target.files[0];
setDossierFile(f || null);
setDossierPreview(null); setDossierResult(null); setDossierErr(null);
if (!f) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const parsed = JSON.parse(ev.target.result);
if (parsed.type !== 'dossier_investissement') {
setDossierErr('Ce fichier n\'est pas un dossier investissement valide (type incorrect).');
return;
}
setDossierPreview(parsed);
} catch { setDossierErr('Fichier JSON invalide — vérifiez la syntaxe.'); }
};
reader.readAsText(f);
};
const onImport = async () => {
if (!dossierPreview) return;
setDossierBusy(true); setDossierErr(null); setDossierResult(null);
try {
const r = await api.post('/imports/dossier', { dossier: dossierPreview });
setDossierResult(r);
setDossierFile(null); setDossierPreview(null);
if (dossierInputRef.current) dossierInputRef.current.value = '';
reloadHistory();
} catch (e) { setDossierErr(e.message); }
finally { setDossierBusy(false); }
};
const dp = dossierPreview;
const inv = dp?.investissement;
return (
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Import Dossier investissement</h3>
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', marginBottom: 12 }}>
Restaure ou migre un dossier complet (investissement + remboursements + historique) depuis un fichier
<code style={{ margin: '0 4px' }}>.json</code> exporté par cette application.
Si le dossier existe déjà, il sera mis à jour ; sinon il sera créé.
</p>
{missingInv && (
<div className="error" style={{ marginBottom: 10 }}>
Sélectionnez un investisseur actif avant d'importer un dossier.
</div>
)}
<div className="row" style={{ gap: 10, alignItems: 'flex-end' }}>
<div style={{ flex: 1 }}>
<label>Fichier dossier <code>.json</code></label>
<input ref={dossierInputRef} type="file" accept=".json"
disabled={missingInv} onChange={onFileChange} />
</div>
</div>
{dossierErr && <div className="error" style={{ marginTop: 10 }}>{dossierErr}</div>}
{dp && inv && (
<div style={{ marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 14 }}>
<h4 style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)' }}>Aperçu du dossier</h4>
<table style={{ marginBottom: 0 }}>
<tbody>
<tr><td style={{ width: 200 }}>Projet</td><td><strong>{inv.nom_projet}</strong></td></tr>
<tr><td>Plateforme</td><td>{dp.plateforme?.nom}</td></tr>
<tr><td>Date souscription</td><td>{fmtDate(inv.date_souscription)}</td></tr>
<tr><td>Montant investi</td><td>{inv.montant_investi} €</td></tr>
<tr><td>Statut</td><td>{inv.statut}</td></tr>
<tr><td>Remboursements</td><td>{dp.remboursements?.length ?? 0} enregistrement(s)</td></tr>
<tr><td>Réinvestissements</td><td>{dp.reinvestissements?.length ?? 0} enregistrement(s)</td></tr>
<tr><td>Projections</td><td>{dp.projections?.length ?? 0} échéance(s)</td></tr>
<tr><td>Historique</td><td>{dp.historique?.length ?? 0} entrée(s)</td></tr>
<tr><td>Exporté le</td><td className="text-muted" style={{ fontSize: 11 }}>{dp.exported_at}</td></tr>
</tbody>
</table>
<div style={{ marginTop: 12, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button onClick={() => { setDossierFile(null); setDossierPreview(null); if (dossierInputRef.current) dossierInputRef.current.value = ''; }}>
Annuler
</button>
<button className="primary" onClick={onImport} disabled={dossierBusy || missingInv}>
{dossierBusy ? '' : 'Importer ce dossier'}
</button>
</div>
</div>
)}
{dossierResult && (
<div className="success-msg" style={{ marginTop: 12 }}>
{dossierResult.action === 'created' ? ' Dossier créé avec succès.' : ' Dossier mis à jour avec succès.'}
{' '}
<button
style={{ marginLeft: 8, fontSize: 'var(--fs-xs)', padding: '2px 8px' }}
onClick={() => navigate(`/investissements/${dossierResult.investissementId}`)}
>
Ouvrir le dossier →
</button>
</div>
)}
</div>
);
}
/* ── Imports — section principale ────────────────────────────── */
export default function ImportsSection() {
const { activeId } = useInvestisseur();
const navigate = useNavigate();
const [module, setModule] = useState('depots_retraits');
const [file, setFile] = useState(null);
const [preview, setPreview] = useState(null);
const [mapping, setMapping] = useState({});
const [defaults, setDefaults] = useState({});
const [plats, setPlats] = useState([]);
const [investissements, setInvestissements] = useState([]);
const [history, setHistory] = useState([]);
const [result, setResult] = useState(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState(null);
const [dossierFile, setDossierFile] = useState(null);
const [dossierPreview, setDossierPreview] = useState(null);
const [dossierResult, setDossierResult] = useState(null);
const [dossierBusy, setDossierBusy] = useState(false);
const [dossierErr, setDossierErr] = useState(null);
const dossierInputRef = useRef(null);
useEffect(() => {
api.get('/imports/history').then(setHistory).catch(() => {});
api.get('/plateformes').then(setPlats).catch(() => {});
}, []);
useEffect(() => {
if (!activeId) return;
api.get('/investissements').then(setInvestissements).catch(() => {});
}, [activeId]);
const def = MODULES[module];
const allTargets = def ? [...def.required, ...def.optional] : [];
const missingInv = def?.needsInvestisseur && !activeId;
const onPreview = async () => {
if (!file) return;
setBusy(true); setErr(null); setResult(null);
try {
const fd = new FormData();
fd.append('file', file);
const r = await api.upload('/imports/preview', fd);
setPreview(r);
const auto = {};
for (const t of allTargets) {
const col = r.headers.find(h => h.toLowerCase().replace(/\W/g, '_') === t);
if (col) auto[t] = col;
}
setMapping(auto);
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
};
const apply = async () => {
setBusy(true); setErr(null);
try {
const r = await api.post('/imports/apply', {
tempId: preview.tempId, module, mapping, defaults,
originalFilename: file?.name ?? preview.filename,
});
setResult({
ok: true,
msg: `✔ Import terminé : ${r.inserted} / ${r.total} lignes insérées${r.skipped > 0 ? `, ${r.skipped} ignorées` : ''}.${r.errors?.length > 0 ? ` (${r.errors.length} avertissement(s))` : ''}`,
});
setPreview(null); setFile(null); setMapping({}); setDefaults({});
api.get('/imports/history').then(setHistory).catch(() => {});
if (module === 'plateformes') api.get('/plateformes').then(setPlats).catch(() => {});
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
};
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
return (
<>
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>1. Fichier source</h3>
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
Importez des données depuis un fichier Excel, CSV ou JSON.
</p>
<div className="row">
<div>
<label>Module cible</label>
<select value={module} onChange={e => {
setModule(e.target.value);
setPreview(null); setMapping({}); setResult(null); setErr(null);
}}>
{Object.entries(MODULES).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
</div>
<div style={{ flex: 2 }}>
<label>Fichier .xlsx, .csv ou .json</label>
<input type="file" accept=".xlsx,.xls,.csv,.json" onChange={e => {
setFile(e.target.files[0]);
setPreview(null); setResult(null); setErr(null);
}} />
</div>
<div>
<button className="primary" onClick={onPreview} disabled={!file || busy || missingInv}>
{busy ? '' : 'Analyser'}
</button>
</div>
</div>
{def?.note && (
<div className="import-module-note">
{def.global && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ flexShrink: 0, marginTop: 1, color: 'var(--warning)' }}>
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
)}
{def.note}
</div>
)}
{missingInv && (
<div className="error" style={{ marginTop: 10 }}>
Sélectionnez un investisseur actif avant d'importer ce module.
</div>
)}
{err && <div className="error" style={{ marginTop: 12 }}>{err}</div>}
<ResultBanner result={result} onDismiss={() => setResult(null)} style={{ marginTop: 12 }} />
</div>
{preview && (
<>
<div className="card">
<h3 style={{ marginTop: 0 }}>2. Mappage des colonnes</h3>
<p className="text-muted" style={{ fontSize: 12 }}>
Fichier : <strong>{preview.filename}</strong> feuille <em>{preview.sheetName}</em> {preview.allRowCount} lignes.
{' '}Champs marqués <span style={{ color: 'var(--danger)' }}>*</span> obligatoires.
{' '}Si la colonne n'existe pas, fournissez une valeur par défaut.
</p>
<table>
<thead>
<tr><th>Champ cible</th><th>Colonne Excel</th><th>Valeur par défaut</th></tr>
</thead>
<tbody>
{allTargets.map(t => {
const isReq = def.required.includes(t);
return (
<tr key={t}>
<td>
<code style={{ fontSize: 11 }}>{t}</code>
{isReq && <span style={{ color: 'var(--danger)' }}> *</span>}
</td>
<td>
<select value={mapping[t] || ''} onChange={e => setMapping({ ...mapping, [t]: e.target.value })}>
<option value="">— ignorer —</option>
{preview.headers.map(h => <option key={h} value={h}>{h}</option>)}
</select>
</td>
<td>
{t === 'plateforme_id' ? (
<select value={defaults[t] || ''} onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}>
<option value="">—</option>
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
</select>
) : t === 'investissement_id' ? (
<select value={defaults[t] || ''} onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}>
<option value="">—</option>
{investissements.map(i => <option key={i.id} value={i.id}>{i.nom_projet}</option>)}
</select>
) : (
<input value={defaults[t] || ''} onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}
placeholder={t === 'statut' ? 'ex. en_cours' : t === 'type' ? 'ex. depot' : ''} />
)}
</td>
</tr>
);
})}
</tbody>
</table>
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => { setPreview(null); setMapping({}); }}>Annuler</button>
<button className="primary" onClick={apply} disabled={busy || missingInv}>
{busy ? '' : `Importer ${preview.allRowCount} lignes`}
</button>
</div>
</div>
<div className="card">
<h3 style={{ marginTop: 0 }}>Aperçu (10 premières lignes)</h3>
<div style={{ overflowX: 'auto' }}>
<table>
<thead><tr>{preview.headers.map(h => <th key={h}>{h}</th>)}</tr></thead>
<tbody>
{preview.sampleRows.map((r, i) => (
<tr key={i}>{preview.headers.map(h => <td key={h}>{String(r[h] ?? '')}</td>)}</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
<DossierImport
activeId={activeId}
navigate={navigate}
dossierFile={dossierFile} setDossierFile={setDossierFile}
dossierPreview={dossierPreview} setDossierPreview={setDossierPreview}
dossierResult={dossierResult} setDossierResult={setDossierResult}
dossierBusy={dossierBusy} setDossierBusy={setDossierBusy}
dossierErr={dossierErr} setDossierErr={setDossierErr}
dossierInputRef={dossierInputRef}
reloadHistory={() => api.get('/imports/history').then(setHistory).catch(() => {})}
/>
<div className="card">
<h3 style={{ marginTop: 0 }}>Historique des imports</h3>
<table>
<thead>
<tr>
<th>Date</th><th>Module</th><th>Fichier</th>
<th className="num">Total</th><th className="num">OK</th><th className="num">KO</th>
</tr>
</thead>
<tbody>
{history.length === 0 && (
<tr><td colSpan={6} className="text-muted" style={{ textAlign: 'center' }}>Aucun import</td></tr>
)}
{history.map(h => (
<tr key={h.id}>
<td>{fmtDate(h.created_at)}</td>
<td>{MODULE_LABEL[h.module] ?? h.module}</td>
<td className="text-muted" style={{ fontSize: 11 }}>{h.filename}</td>
<td className="num">{h.rows_total}</td>
<td className="num" style={{ color: 'var(--success)' }}>{h.rows_inserted}</td>
<td className="num" style={{ color: h.rows_skipped > 0 ? 'var(--warning)' : undefined }}>{h.rows_skipped}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}
@@ -0,0 +1,94 @@
import { useState } from 'react';
import { useUi } from '../../context/UiContext.jsx';
export default function MaFiscaliteSection() {
const { pfoAssujetti, setPfoAssujetti } = useUi();
const [showPfoDetail, setShowPfoDetail] = useState(false);
return (
<div>
<h2 style={{ margin: '0 0 6px', fontSize: 18 }}>Ma fiscalité</h2>
<p style={{ margin: '0 0 24px', color: 'var(--text-muted)', fontSize: 13 }}>
Paramètres fiscaux personnels applicables à vos revenus de placements.
</p>
<div style={{ fontWeight: 700, fontSize: 'var(--fs-md)', marginBottom: 10 }}>
Fiscalité des plateformes étrangères
</div>
<div className="card" style={{ marginBottom: 16, borderLeft: `4px solid ${pfoAssujetti ? 'var(--primary)' : 'var(--border)'}`, transition: 'border-color .2s' }}>
{/* Ligne titre + chevron + toggle */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<button
type="button"
onClick={() => setShowPfoDetail(v => !v)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex', alignItems: 'center', color: 'var(--text)' }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ transform: showPfoDetail ? 'rotate(180deg)' : 'none', transition: 'transform .2s', marginRight: 6, flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
<span style={{ fontWeight: 700, fontSize: 'var(--fs-base)' }}>
CERFA 2778-SD Prélèvement Forfaitaire Obligatoire (PFO) pour des revenus de source étrangère
</span>
</button>
<div style={{ marginLeft: 'auto', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 'var(--fs-xs)', fontWeight: 600, color: pfoAssujetti ? 'var(--primary)' : 'var(--text-muted)' }}>
{pfoAssujetti ? 'Activé' : 'Désactivé'}
</span>
<button
type="button"
onClick={() => setPfoAssujetti(!pfoAssujetti)}
style={{
width: 48, height: 26, borderRadius: 13, border: 'none',
background: pfoAssujetti ? 'var(--primary)' : 'var(--border)',
cursor: 'pointer', position: 'relative', transition: 'background .2s',
}}
>
<span style={{
position: 'absolute', top: 3,
left: pfoAssujetti ? 25 : 3,
width: 20, height: 20, borderRadius: '50%',
background: '#fff', transition: 'left .2s',
boxShadow: '0 1px 4px rgba(0,0,0,0.2)',
}} />
</button>
</div>
</div>
{showPfoDetail && (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)', lineHeight: 1.6 }}>
Les intérêts perçus via des plateformes <strong>étrangères</strong> sont soumis à un prélèvement
forfaitaire obligatoire non libératoire de <strong>12,8 %</strong> (+ prélèvements sociaux),
à déclarer mensuellement via le formulaire <strong>2778-SD</strong> dans les 15 premiers jours
du mois suivant l'encaissement.
</div>
<div style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)', lineHeight: 1.6, marginTop: 8 }}>
Ce prélèvement s'applique aux personnes physiques fiscalement domiciliées en France dont
le <strong>revenu fiscal de référence</strong> de l'avant-dernière année est égal ou supérieur à :
</div>
<div style={{ display: 'flex', gap: 12, marginTop: 8, flexWrap: 'wrap' }}>
<div style={{
padding: '6px 14px', borderRadius: 20,
background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.2)',
fontSize: 'var(--fs-sm)', fontWeight: 600,
}}>25 000 € — célibataire, divorcé ou veuf</div>
<div style={{
padding: '6px 14px', borderRadius: 20,
background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.2)',
fontSize: 'var(--fs-sm)', fontWeight: 600,
}}>50 000 € — couple marié ou pacsé</div>
</div>
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 8 }}>
En dessous de ces seuils, vous êtes dispensé du PFO — mais les prélèvements sociaux restent dus.
Le PFO versé via la 2778-SD constitue un simple acompte d'impôt sur le revenu,
imputable sur l'impôt définitif calculé lors de la déclaration 2042.
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,736 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../../api.js';
import InvSelect from '../../components/InvSelect.jsx';
import Modal from '../../components/Modal.jsx';
import ConfirmModal from '../../components/ConfirmModal.jsx';
import CountrySelect, { COUNTRIES, FlagIcon } from '../../components/CountrySelect.jsx';
import ResultBanner from '../../components/ResultBanner.jsx';
import { useInvestisseur } from '../../context/InvestisseurContext.jsx';
import { memberLabel, fmtDate } from '../../utils/format.js';
function dlBlob(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code ?? '—';
const FISCALITE_LABELS = {
flat_tax: 'Flat Tax',
sans_fiscalite_locale: 'Sans fiscalité locale',
avec_fiscalite_locale: 'Avec fiscalité locale',
};
const METHODE_REMB_LABELS = {
portefeuille: 'Porte-monnaie de la plateforme',
compte_courant: "Compte courant de l'investisseur",
choix_investisseur: "Au choix de l'investisseur (sur la plateforme)",
};
/** Reconstruit un objet investisseur minimal depuis les colonnes dénormalisées de la plateforme */
function platInvestisseur(p) {
if (!p.investisseur_id) return null;
return { id: p.investisseur_id, nom: p.investisseur_nom, prenom: p.investisseur_prenom, type: p.investisseur_type, type_fiscal: p.investisseur_type_fiscal };
}
function fmtFiscalite(p) {
if (!p.fiscalite) return '—';
const base = FISCALITE_LABELS[p.fiscalite] ?? p.fiscalite;
if (p.fiscalite === 'avec_fiscalite_locale' && p.taux_fiscalite_locale != null) {
return `${base} (${p.taux_fiscalite_locale} %)`;
}
return base;
}
const EMPTY_PLAT = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', methode_remboursement: 'portefeuille', investisseur_id: null, date_ouverture: '', logo_filename: null, type_pret_defaut: '', freq_interets_defaut: '', referentiel_id: null };
const LOGO_BASE = (import.meta.env.VITE_API_URL || '/api').replace(/\/api$/, '') + '/api/logos/';
const logoUrl = (filename) => filename ? LOGO_BASE + filename : null;
function applyDomiciliationChange(state, newDomicil) {
const next = { ...state, domiciliation: newDomicil };
if (newDomicil === 'FR') {
next.fiscalite = 'flat_tax';
next.taux_fiscalite_locale = '';
} else if (state.fiscalite === 'flat_tax') {
next.fiscalite = 'sans_fiscalite_locale';
next.taux_fiscalite_locale = '';
}
return next;
}
function applyFiscaliteChange(state, newFiscalite) {
const next = { ...state, fiscalite: newFiscalite };
if (newFiscalite !== 'avec_fiscalite_locale') next.taux_fiscalite_locale = '';
return next;
}
function platsToCSV(plats) {
const BOM = '';
const sep = ';';
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
const headers = ['ID', 'Nom', 'URL', 'Domiciliation', 'Fiscalité', 'Taux fiscal local (%)', 'Créé le'];
const rows = plats.map(p => [
p.id, p.nom, p.url || '',
countryLabel(p.domiciliation),
FISCALITE_LABELS[p.fiscalite] ?? p.fiscalite ?? '',
p.taux_fiscalite_locale ?? '',
p.created_at ? new Date(p.created_at).toLocaleDateString('fr-FR') : '',
]);
return BOM + [headers, ...rows].map(r => r.map(q).join(sep)).join('\r\n');
}
function platsToXLS(plats) {
const esc = v => String(v ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
const cell = (v, t = 'String') => `<Cell><Data ss:Type="${t}">${esc(v)}</Data></Cell>`;
const mkRow = cells => ` <Row>${cells.join('')}</Row>`;
const header = mkRow(['ID','Nom','URL','Domiciliation','Fiscalité','Taux fiscal local (%)','Créé le'].map(h => cell(h)));
const dataRows = plats.map(p => mkRow([
cell(p.id, 'Number'), cell(p.nom), cell(p.url || ''),
cell(countryLabel(p.domiciliation)),
cell(FISCALITE_LABELS[p.fiscalite] ?? p.fiscalite ?? ''),
cell(p.taux_fiscalite_locale ?? ''),
cell(p.created_at ? new Date(p.created_at).toLocaleDateString('fr-FR') : ''),
])).join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
<Styles><Style ss:ID="h"><Font ss:Bold="1"/></Style></Styles>
<Worksheet ss:Name="Plateformes">
<Table>
${header}
${dataRows}
</Table>
</Worksheet>
</Workbook>`;
}
/* ── Icônes utilitaires ──────────────────────────────────────── */
function IconExport() {
return <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>;
}
function IconCSV() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="10" y1="9" x2="14" y2="9"/></svg>;
}
function IconXLS() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l2 2 4-4"/></svg>;
}
function IconJSON() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M8 13h1.5a1.5 1.5 0 0 1 0 3H8v-3z"/><path d="M14 13h2v1.5a1.5 1.5 0 0 1-3 0V13z"/></svg>;
}
function IconImport() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 14 12 9 17 14"/><line x1="12" y1="9" x2="12" y2="21"/></svg>;
}
/* ── ExportDropdown ──────────────────────────────────────────── */
function ExportDropdown({ disabled, onCSV, onXLS, onJSON, title = 'Exporter' }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const handler = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const choose = (fn) => { setOpen(false); fn(); };
return (
<div ref={ref} style={{ position: 'relative' }}>
<button type="button" className="icon-btn" disabled={disabled}
onClick={() => setOpen(o => !o)} title={title}
aria-haspopup="menu" aria-expanded={open}>
<IconExport />
</button>
{open && (
<div className="export-dropdown" role="menu">
<button role="menuitem" onClick={() => choose(onCSV)}>
<IconCSV /><span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
</button>
<button role="menuitem" onClick={() => choose(onXLS)}>
<IconXLS /><span><strong>Format Excel</strong><small>Fichier .xls natif Microsoft</small></span>
</button>
{onJSON && (
<button role="menuitem" onClick={() => choose(onJSON)}>
<IconJSON /><span><strong>Format JSON</strong><small>Données structurées</small></span>
</button>
)}
</div>
)}
</div>
);
}
/* ── Helpers PFU ─────────────────────────────────────────────── */
function PlatDetailPanel({ plat, onEdit }) {
const navigate = useNavigate();
const [platCatsInv, setPlatCatsInv] = useState([]);
const [platSectsInv, setPlatSectsInv] = useState([]);
useEffect(() => {
if (!plat) { setPlatCatsInv([]); setPlatSectsInv([]); return; }
Promise.all([
api.get(`/plateformes/${plat.id}/categories-inv`).catch(() => []),
api.get(`/plateformes/${plat.id}/secteurs-inv`).catch(() => []),
]).then(([cats, sects]) => { setPlatCatsInv(cats); setPlatSectsInv(sects); });
}, [plat?.id]);
if (!plat) return (
<div className="dr-detail dr-detail-empty">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.25, marginBottom: 8 }}>
<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
<span>Sélectionnez une plateforme</span>
</div>
);
const inv = platInvestisseur(plat);
const fields = [
{ label: 'Plateforme', value: plat.nom },
plat.url && {
label: 'Site web',
value: <a href={plat.url} target="_blank" rel="noreferrer"
style={{ wordBreak: 'break-all', color: 'var(--primary)' }}>{plat.url}</a>,
},
{ label: 'Détenteur', value: inv ? memberLabel(inv) : '—' },
plat.date_ouverture && { label: "Date d'ouverture", value: fmtDate(plat.date_ouverture) },
{ label: 'Domiciliation', value: plat.domiciliation ? <span style={{ display:'inline-flex', alignItems:'center', gap:5 }}><FlagIcon code={plat.domiciliation} size={16} />{countryLabel(plat.domiciliation)}</span> : '—' },
{ label: 'Fiscalité', value: fmtFiscalite(plat) },
plat.domiciliation === 'FR' && {
label: 'Déclaration 2561',
value: (plat.type_produit_fiscal ?? '2TT') === '2TR'
? 'Case 2TR — Produits de placement à revenu fixe'
: 'Case 2TT — Produits des minibons et prêts participatifs',
},
{ label: 'Méthode de remboursement', value: METHODE_REMB_LABELS[plat.methode_remboursement] ?? '—' },
platCatsInv.length > 0 && {
label: "Catégories d'investissement",
value: <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>{platCatsInv.map(c => <span key={c.id} className="chip-cat">{c.nom}</span>)}</div>,
},
platSectsInv.length > 0 && {
label: "Secteurs d'investissement",
value: <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>{platSectsInv.map(s => <span key={s.id} className="chip-sect">{s.nom}</span>)}</div>,
},
plat.type_pret_defaut && { label: 'Type de prêt (défaut)', value: { in_fine: 'In fine', amortissable: 'Amortissable', differe: 'Différé' }[plat.type_pret_defaut] ?? plat.type_pret_defaut },
plat.freq_interets_defaut && { label: 'Périodicité (défaut)', value: { mensuel: 'Mensuelle', trimestriel: 'Trimestrielle', in_fine: 'In fine' }[plat.freq_interets_defaut] ?? plat.freq_interets_defaut },
{
label: 'Investissements',
value: plat.nb_investissements != null
? `${plat.nb_investissements} investissement${plat.nb_investissements !== 1 ? 's' : ''}`
: '—',
},
plat.notes && { label: 'Notes', value: plat.notes },
].filter(Boolean);
const logo = logoUrl(plat.logo_filename);
return (
<div className="dr-detail">
{logo && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '12px 0 4px' }}>
<img src={logo} alt={`Logo ${plat.nom}`} className="logo-plateforme"
style={{ maxHeight: 56, maxWidth: 160, objectFit: 'contain' }}
onError={e => { e.currentTarget.style.display = 'none'; }}
/>
</div>
)}
<div className="dr-detail-title">Détail de la plateforme</div>
<div className="dr-detail-fields">
{fields.map(f => (
<div className="dr-detail-field" key={f.label}>
<span className="dr-detail-label">{f.label}</span>
<span className="dr-detail-value">{f.value}</span>
</div>
))}
</div>
<div className="dr-detail-footer">
{plat.referentiel_id && (
<button
className="ghost"
onClick={() => navigate(`/referentiel/${plat.referentiel_id}`)}
style={{ marginBottom: 8, width: '100%', fontSize: 13 }}
>
Voir le profil de la plateforme
</button>
)}
<button className="dr-detail-edit-btn" onClick={() => onEdit(plat)}>
Modifier la plateforme
</button>
</div>
</div>
);
}
/* ── Panneau de détail PFU ───────────────────────────────────── */
export default function PlateformesSection() {
// ── State ──────────────────────────────────────────────────────
const [plats, setPlats] = useState([]);
const [investisseurs, setInvestisseurs] = useState([]);
const [referentiel, setReferentiel] = useState([]);
const [newPlat, setNewPlat] = useState(EMPTY_PLAT);
const [newPlatLogoFile, setNewPlatLogoFile] = useState(null);
const [newPlatLogoPreview, setNewPlatLogoPreview] = useState(null);
const [editPlat, setEditPlat] = useState(null);
const [editPlatLogoFile, setEditPlatLogoFile] = useState(null);
const [editPlatLogoPreview, setEditPlatLogoPreview] = useState(null);
const [selectedPlat, setSelectedPlat] = useState(null);
const [showNewPlat, setShowNewPlat] = useState(false);
const [platOpenMenu, setPlatOpenMenu] = useState(null); // { plat, x, y }
const [platExporting, setPlatExporting] = useState(false);
const [platImportResult, setPlatImportResult] = useState(null);
// Catégories d'investissement (globales + privées)
const [categoriesInv, setCategoriesInv] = useState([]);
const [selectedCatInv, setSelectedCatInv] = useState(null);
const [editingCatInv, setEditingCatInv] = useState(null); // id en cours d'édition
const [editingNomCatInv, setEditingNomCatInv] = useState('');
const [newCatInvNom, setNewCatInvNom] = useState('');
const [showNewCatInv, setShowNewCatInv] = useState(false);
// Secteurs d'investissement (globaux + privés)
const [secteursInv, setSecteursInv] = useState([]);
const [selectedSectInv, setSelectedSectInv] = useState(null);
const [editingSectInv, setEditingSectInv] = useState(null);
const [editingNomSectInv, setEditingNomSectInv] = useState('');
const [newSectInvNom, setNewSectInvNom] = useState('');
const [showNewSectInv, setShowNewSectInv] = useState(false);
// PFU
const [showPfoDetail, setShowPfoDetail] = useState(false);
const [err, setErr] = useState(null);
const [msg, setMsg] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const platImportRef = useRef(null);
const load = async () => {
const [p, invs, ref, catInv, sectInv] = await Promise.all([
api.get('/plateformes'),
api.get('/investisseurs'),
api.get('/plateformes/referentiel-list'),
api.get('/categories-inv'),
api.get('/secteurs-inv'),
]);
setPlats(p);
setInvestisseurs(invs);
setReferentiel(ref);
setCategoriesInv(catInv);
setSecteursInv(sectInv);
setSelectedPlat(prev => prev ? (p.find(x => x.id === prev.id) ?? null) : null);
};
useEffect(() => { load(); }, []); // eslint-disable-line
useEffect(() => {
if (plats.length === 0) return;
setSelectedPlat(prev => prev ? prev : plats[0]);
}, [plats]); // eslint-disable-line
const handleLogoFile = (file, setFile, setPreview) => {
if (!file) { setFile(null); setPreview(null); return; }
setFile(file);
const reader = new FileReader();
reader.onload = (ev) => setPreview(ev.target.result);
reader.readAsDataURL(file);
};
/* ── Catégories d'investissement (privées) ──────────────────────── */
const saveCatInv = async (nom) => {
if (!nom.trim()) return;
try {
const row = await api.post('/categories-inv', { nom: nom.trim() });
setCategoriesInv(prev => [...prev, row].sort((a, b) =>
b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
setShowNewCatInv(false); setNewCatInvNom(''); setErr(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const renameCatInv = async (id, nom) => {
if (!nom.trim()) return;
try {
await api.put(`/categories-inv/${id}`, { nom: nom.trim() });
setCategoriesInv(prev => prev.map(c => c.id === id ? { ...c, nom: nom.trim() } : c));
setEditingCatInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const delCatInv = async (id) => {
try {
await api.del(`/categories-inv/${id}`);
setCategoriesInv(prev => prev.filter(c => c.id !== id));
if (selectedCatInv?.id === id) setSelectedCatInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
/* ── Secteurs d'investissement (privés) ──────────────────────── */
const saveSectInv = async (nom) => {
if (!nom.trim()) return;
try {
const row = await api.post('/secteurs-inv', { nom: nom.trim() });
setSecteursInv(prev => [...prev, row].sort((a, b) =>
b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
setShowNewSectInv(false); setNewSectInvNom(''); setErr(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const renameSectInv = async (id, nom) => {
if (!nom.trim()) return;
try {
await api.put(`/secteurs-inv/${id}`, { nom: nom.trim() });
setSecteursInv(prev => prev.map(s => s.id === id ? { ...s, nom: nom.trim() } : s));
setEditingSectInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const delSectInv = async (id) => {
try {
await api.del(`/secteurs-inv/${id}`);
setSecteursInv(prev => prev.filter(s => s.id !== id));
if (selectedSectInv?.id === id) setSelectedSectInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
/** Upload le logo vers le serveur après que la plateforme a été créée/sauvée */
const uploadLogo = async (platId, file) => {
if (!file) return null;
const fd = new FormData();
fd.append('logo', file);
return api.upload(`/plateformes/${platId}/logo`, fd);
};
/* ── Plateformes ─────────────────────────────────────────────── */
const addPlat = async (e) => {
e.preventDefault(); setErr(null); setMsg(null);
try {
const created = await api.post('/plateformes', {
...newPlat,
taux_fiscalite_locale: newPlat.fiscalite === 'avec_fiscalite_locale' && newPlat.taux_fiscalite_locale !== ''
? Number(newPlat.taux_fiscalite_locale) : null,
methode_remboursement: newPlat.methode_remboursement || 'portefeuille',
investisseur_id: newPlat.investisseur_id ? Number(newPlat.investisseur_id) : null,
date_ouverture: newPlat.date_ouverture || null,
type_pret_defaut: newPlat.type_pret_defaut || null,
freq_interets_defaut: newPlat.freq_interets_defaut || null,
referentiel_id: newPlat.referentiel_id ? Number(newPlat.referentiel_id) : null,
});
if (newPlatLogoFile) await uploadLogo(created.id, newPlatLogoFile);
setNewPlat(EMPTY_PLAT);
setNewPlatLogoFile(null);
setNewPlatLogoPreview(null);
setShowNewPlat(false);
await load();
} catch (e) { setErr(e.message); }
};
const delPlat = (id) => {
setConfirmDelete({
message: 'Supprimer cette plateforme ?',
onConfirm: async () => {
try {
await api.del(`/plateformes/${id}`);
setSelectedPlat(null);
await load();
} catch (e) { setErr(e.message); }
finally { setConfirmDelete(null); }
},
});
};
// ── Export/Import ZIP plateformes ─────────────────────────────────────
const handlePlatExportAll = async () => {
try {
setPlatExporting(true);
const blob = await api.blob('/plateformes/export');
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `plateformes-${new Date().toISOString().slice(0, 10)}.zip`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) { setPlatImportResult({ ok: false, msg: e.message }); }
finally { setPlatExporting(false); }
};
const handlePlatExportOne = async (plat) => {
try {
const blob = await api.blob(`/plateformes/${plat.id}/export`);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${plat.nom.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${new Date().toISOString().slice(0, 10)}.zip`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) { setPlatImportResult({ ok: false, msg: e.message }); }
};
const handlePlatImportZip = async (file) => {
try {
const fd = new FormData();
fd.append('file', file);
const r = await api.upload('/plateformes/import-zip', fd);
setPlatImportResult({ ok: true, msg: `Import terminé : ${r.created} créée(s), ${r.updated} mise(s) à jour sur ${r.total} entrée(s).` });
load();
} catch (e) { setPlatImportResult({ ok: false, msg: e.message }); }
};
const openEditPlat = async (p) => {
setEditPlatLogoFile(null);
setEditPlatLogoPreview(null);
// Charger les associations catégories/secteurs d'investissement de la plateforme
const [platCatsInv, platSectsInv] = await Promise.all([
api.get(`/plateformes/${p.id}/categories-inv`).catch(() => []),
api.get(`/plateformes/${p.id}/secteurs-inv`).catch(() => []),
]);
setEditPlat({
id: p.id, nom: p.nom, url: p.url || '',
categories_inv_ids: platCatsInv.map(c => c.id),
secteurs_inv_ids: platSectsInv.map(s => s.id),
inherited_cat_ids: platCatsInv.filter(c => c.is_inherited).map(c => c.id),
inherited_sect_ids: platSectsInv.filter(s => s.is_inherited).map(s => s.id),
domiciliation: p.domiciliation || 'france',
fiscalite: p.fiscalite || 'flat_tax',
taux_fiscalite_locale: p.taux_fiscalite_locale ?? '',
type_produit_fiscal: p.type_produit_fiscal || '2TT',
methode_remboursement: p.methode_remboursement || 'portefeuille',
investisseur_id: p.investisseur_id ?? null,
date_ouverture: p.date_ouverture || '',
logo_filename: p.logo_filename || null,
type_pret_defaut: p.type_pret_defaut || '',
freq_interets_defaut: p.freq_interets_defaut || '',
referentiel_id: p.referentiel_id ?? null,
referentiel_nom: p.referentiel_nom ?? null,
overridden_fields: p.overridden_fields ?? [],
});
};
const saveEditPlat = async (e) => {
e.preventDefault(); setErr(null); setMsg(null);
try {
await api.put(`/plateformes/${editPlat.id}`, {
nom: editPlat.nom, url: editPlat.url || '',
domiciliation: editPlat.domiciliation,
fiscalite: editPlat.fiscalite,
taux_fiscalite_locale: editPlat.fiscalite === 'avec_fiscalite_locale' && editPlat.taux_fiscalite_locale !== ''
? Number(editPlat.taux_fiscalite_locale) : null,
type_produit_fiscal: editPlat.type_produit_fiscal || '2TT',
methode_remboursement: editPlat.methode_remboursement || 'portefeuille',
investisseur_id: editPlat.investisseur_id ? Number(editPlat.investisseur_id) : null,
date_ouverture: editPlat.date_ouverture || null,
type_pret_defaut: editPlat.type_pret_defaut || null,
freq_interets_defaut: editPlat.freq_interets_defaut || null,
});
if (editPlatLogoFile) await uploadLogo(editPlat.id, editPlatLogoFile);
// Sauvegarder les associations catégories/secteurs d'investissement
await Promise.all([
api.put(`/plateformes/${editPlat.id}/categories-inv`, { ids: editPlat.categories_inv_ids || [] }),
api.put(`/plateformes/${editPlat.id}/secteurs-inv`, { ids: editPlat.secteurs_inv_ids || [] }),
]);
setMsg('Plateforme mise à jour.');
setTimeout(() => setMsg(null), 3000);
setEditPlatLogoFile(null);
setEditPlatLogoPreview(null);
setEditPlat(null);
await load();
} catch (e) { setErr(e.message); }
};
const delLogoPlat = async (id) => {
try {
await api.del(`/plateformes/${id}/logo`);
setEditPlat(ep => ep ? { ...ep, logo_filename: null } : ep);
await load();
} catch (e) { setErr(e.message); }
};
/* ── PFU CRUD ────────────────────────────────────────────────── */
return (
<>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
{msg && <div className="success-msg" style={{ marginBottom: 12 }}>{msg}</div>}
<div className="dr-mouvements-layout">
{/* Colonne gauche — liste */}
<div className="dr-mouvements-list">
<div className="card" style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div>
<h3 style={{ margin: 0 }}>Mes plateformes</h3>
<p className="text-muted" style={{ margin: '4px 0 0', fontSize: 'var(--fs-sm)' }}>
Plateformes de crowdlending que vous utilisez.
</p>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<ExportDropdown
disabled={plats.length === 0}
onCSV={() => dlBlob(platsToCSV(plats), 'plateformes.csv', 'text/csv;charset=utf-8')}
onXLS={() => dlBlob(platsToXLS(plats), 'plateformes.xls', 'application/vnd.ms-excel')}
/>
<button onClick={handlePlatExportAll} disabled={platExporting || plats.length === 0}
title="Exporter toutes les plateformes en ZIP"
style={{ padding: '7px 12px', borderRadius: 6, border: '1px solid var(--border)', fontSize: 13, fontWeight: 600, cursor: 'pointer', background: 'var(--surface-2)', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 5 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
{platExporting ? '…' : 'Export ZIP'}
</button>
<button onClick={() => platImportRef.current?.click()}
title="Importer un fichier ZIP de plateformes"
style={{ padding: '7px 12px', borderRadius: 6, border: '1px solid var(--border)', fontSize: 13, fontWeight: 600, cursor: 'pointer', background: 'var(--surface-2)', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 5 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Import ZIP
</button>
<input ref={platImportRef} type="file" accept=".zip" style={{ display: 'none' }}
onChange={e => { const f = e.target.files[0]; e.target.value = ''; if (f) handlePlatImportZip(f); }} />
<button className="primary" type="button"
onClick={() => { setNewPlat(EMPTY_PLAT); setErr(null); setShowNewPlat(true); }}>
+ Ajouter
</button>
</div>
</div>
{platImportResult && <ResultBanner result={platImportResult} onDismiss={() => setPlatImportResult(null)} style={{ marginBottom: 12 }} />}
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
<th style={{ padding: '10px 8px', width: 40 }}></th>
<th style={{ padding: '10px 16px', textAlign: 'left' }}>Nom</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Domiciliation</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Catégories</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Secteurs</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Détenteur</th>
<th style={{ padding: '10px 16px', textAlign: 'center', width: 60 }}>Invest.</th>
<th style={{ padding: '10px 8px', width: 40 }}></th>
</tr>
</thead>
<tbody>
{plats.length === 0 && (
<tr><td colSpan={7} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune plateforme</td></tr>
)}
{plats.map((p, i) => {
const imgSrc = p.icone_filename ? logoUrl(p.icone_filename) : p.logo_filename ? logoUrl(p.logo_filename) : null;
const inv = platInvestisseur(p);
const cats = p.categories_inv || [];
const sects = p.secteurs_inv || [];
return (
<tr key={p.id}
onClick={() => setSelectedPlat(selectedPlat?.id === p.id ? null : p)}
style={{
borderBottom: i < plats.length - 1 ? '1px solid var(--border)' : 'none',
cursor: 'pointer',
background: selectedPlat?.id === p.id ? 'var(--surface-2)' : 'none',
}}
onMouseEnter={e => { if (selectedPlat?.id !== p.id) e.currentTarget.style.background = 'var(--surface-2)'; }}
onMouseLeave={e => { if (selectedPlat?.id !== p.id) e.currentTarget.style.background = 'none'; }}>
<td style={{ padding: '8px 8px 8px 16px', width: 40 }}>
{imgSrc
? <img src={imgSrc} alt="" style={{ width: 32, height: 32, objectFit: 'contain', borderRadius: 4, display: 'block' }} />
: <div style={{ width: 32, height: 32, borderRadius: 4, background: 'var(--surface-2)', border: '1px dashed var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-muted)', fontSize: 16 }}>×</div>
}
</td>
<td style={{ padding: '10px 16px' }}>
<div style={{ fontWeight: 600 }}>{p.nom}</div>
</td>
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}>
{p.domiciliation
? <span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<FlagIcon code={p.domiciliation} size={15} />{countryLabel(p.domiciliation)}
</span>
: '—'}
</td>
<td style={{ padding: '10px 8px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{cats.slice(0, 2).map(c => <span key={c.id} className="chip-cat" style={{ fontSize: 11 }}>{c.nom}</span>)}
{cats.length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{cats.length - 2}</span>}
{cats.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}></span>}
</div>
</td>
<td style={{ padding: '10px 8px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{sects.slice(0, 2).map(s => <span key={s.id} className="chip-sect" style={{ fontSize: 11 }}>{s.nom}</span>)}
{sects.length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{sects.length - 2}</span>}
{sects.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}></span>}
</div>
</td>
<td style={{ padding: '10px 8px', fontSize: 12, color: 'var(--text-muted)' }}>
{inv ? memberLabel(inv) : '—'}
</td>
<td style={{ padding: '10px 16px', textAlign: 'center', fontWeight: 600,
color: (p.nb_investissements ?? 0) > 0 ? 'var(--text)' : 'var(--text-muted)' }}>
{p.nb_investissements ?? 0}
</td>
<td style={{ padding: '10px 8px', textAlign: 'right' }}>
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 4, fontSize: 18, color: 'var(--text-muted)', lineHeight: 1 }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={e => { e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); setPlatOpenMenu({ plat: p, x: rect.right, y: rect.bottom }); }}
></button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
{/* Colonne droite — détail */}
<div className="dr-mouvements-detail">
<PlatDetailPanel
plat={selectedPlat}
onEdit={openEditPlat}
/>
</div>
</div>
{/* Menu ⋮ plateformes */}
{platOpenMenu && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setPlatOpenMenu(null)} />
<div style={{ position: 'fixed', left: platOpenMenu.x, top: platOpenMenu.y,
transform: 'translateX(-100%) translateY(4px)', zIndex: 300,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)', padding: '4px 0', minWidth: 170 }}>
{[
{ icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>, label: 'Modifier',
onClick: () => { const p = platOpenMenu.plat; setPlatOpenMenu(null); openEditPlat(p); } },
{ icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>, label: 'Exporter',
onClick: () => { const p = platOpenMenu.plat; setPlatOpenMenu(null); handlePlatExportOne(p); } },
{ icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>, label: 'Supprimer',
onClick: () => { const p = platOpenMenu.plat; setPlatOpenMenu(null); delPlat(p.id); }, color: 'var(--danger)' },
].map(({ icon, label, onClick, color }) => (
<button key={label}
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px',
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 'var(--fs-sm)', color: color || 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={onClick}>
<span style={{ opacity: 0.7, flexShrink: 0, display: 'flex' }}>{icon}</span>
{label}
</button>
))}
</div>
</>
)}
<ConfirmModal
open={!!confirmDelete}
title="Supprimer la plateforme"
message={confirmDelete?.message}
onConfirm={confirmDelete?.onConfirm}
onCancel={() => setConfirmDelete(null)}
/>
</>
);
}
@@ -0,0 +1,196 @@
import { useState, useEffect } from 'react';
import { api } from '../../api.js';
export default function SecteursInvSection() {
const [secteursInv, setSecteursInv] = useState([]);
const [err, setErr] = useState(null);
const [selectedSectInv, setSelectedSectInv] = useState(null);
const [editingSectInv, setEditingSectInv] = useState(null);
const [editingNomSectInv, setEditingNomSectInv] = useState('');
const [newSectInvNom, setNewSectInvNom] = useState('');
const [showNewSectInv, setShowNewSectInv] = useState(false);
const [sectGlobalOpen, setSectGlobalOpen] = useState(false);
const [sectPrivateOpen, setSectPrivateOpen] = useState(true);
useEffect(() => {
api.get('/secteurs-inv').then(data => {
setSecteursInv(data.sort((a, b) => b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
}).catch(() => {});
}, []);
const saveSectInv = async (nom) => {
if (!nom.trim()) return;
try {
const row = await api.post('/secteurs-inv', { nom: nom.trim() });
setSecteursInv(prev => [...prev, row].sort((a, b) =>
b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
setShowNewSectInv(false); setNewSectInvNom(''); setErr(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const renameSectInv = async (id, nom) => {
if (!nom.trim()) return;
try {
await api.put(`/secteurs-inv/${id}`, { nom: nom.trim() });
setSecteursInv(prev => prev.map(s => s.id === id ? { ...s, nom: nom.trim() } : s));
setEditingSectInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const delSectInv = async (id) => {
try {
await api.del(`/secteurs-inv/${id}`);
setSecteursInv(prev => prev.filter(s => s.id !== id));
if (selectedSectInv?.id === id) setSelectedSectInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const globalSects = secteursInv.filter(s => s.is_global);
const privateSects = secteursInv.filter(s => !s.is_global);
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<h3 style={{ margin: 0 }}>Mes secteurs d'investissement</h3>
</div>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
{/* Accordéon — Secteurs globalement définis */}
<div className="card" style={{ marginBottom: 10 }}>
<button type="button"
onClick={() => setSectGlobalOpen(o => !o)}
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--text)' }}>
<span style={{ fontWeight: 600, fontSize: 'var(--fs-base)' }}>
Secteurs globalement définis
<span style={{ marginLeft: 8, fontWeight: 400, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
({globalSects.length})
</span>
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
style={{ transform: sectGlobalOpen ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{sectGlobalOpen && (
<div style={{ marginTop: 14 }}>
<table>
<thead>
<tr>
<th>Nom</th>
<th className="num">Plateformes</th>
<th className="num">Investissements</th>
</tr>
</thead>
<tbody>
{globalSects.length === 0 && (
<tr><td colSpan={3} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucun secteur global</td></tr>
)}
{globalSects.map(s => (
<tr key={s.id}>
<td><span style={{ fontWeight: 600 }}>{s.nom}</span></td>
<td className="num">{s.nb_plateformes > 0 ? s.nb_plateformes : <span className="text-muted">—</span>}</td>
<td className="num">{s.nb_investissements > 0 ? s.nb_investissements : <span className="text-muted">—</span>}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Accordéon — Mes propres secteurs */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<button type="button"
onClick={() => setSectPrivateOpen(o => !o)}
style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 8,
background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--text)', textAlign: 'left' }}>
<span style={{ fontWeight: 600, fontSize: 'var(--fs-base)' }}>
Mes propres secteurs
<span style={{ marginLeft: 8, fontWeight: 400, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
({privateSects.length})
</span>
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
style={{ transform: sectPrivateOpen ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{sectPrivateOpen && (
<button className="primary" type="button" style={{ marginLeft: 12, flexShrink: 0 }}
onClick={() => { setSectPrivateOpen(true); setShowNewSectInv(true); setNewSectInvNom(''); setErr(null); }}>
+ Ajouter
</button>
)}
</div>
{sectPrivateOpen && (
<div style={{ marginTop: 14 }}>
{showNewSectInv && (
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<input autoFocus style={{ flex: 1 }} placeholder="Nom du secteur"
value={newSectInvNom} onChange={e => setNewSectInvNom(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') saveSectInv(newSectInvNom); if (e.key === 'Escape') setShowNewSectInv(false); }} />
<button className="primary" onClick={() => saveSectInv(newSectInvNom)}>Créer</button>
<button onClick={() => setShowNewSectInv(false)}>Annuler</button>
</div>
)}
<table>
<thead>
<tr>
<th>Nom</th>
<th className="num">Plateformes</th>
<th className="num">Investissements</th>
<th style={{ width: 80 }}></th>
</tr>
</thead>
<tbody>
{privateSects.length === 0 && (
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucun secteur personnel</td></tr>
)}
{privateSects.map(s => (
<tr key={s.id}>
<td>
{editingSectInv === s.id ? (
<div style={{ display: 'flex', gap: 6 }}>
<input autoFocus style={{ flex: 1 }} value={editingNomSectInv}
onChange={e => setEditingNomSectInv(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') renameSectInv(s.id, editingNomSectInv); if (e.key === 'Escape') setEditingSectInv(null); }} />
<button className="primary" onClick={() => renameSectInv(s.id, editingNomSectInv)}>OK</button>
<button onClick={() => setEditingSectInv(null)}>✕</button>
</div>
) : (
<span style={{ fontWeight: 600 }}>{s.nom}</span>
)}
</td>
<td className="num">{s.nb_plateformes > 0 ? s.nb_plateformes : <span className="text-muted">—</span>}</td>
<td className="num">{s.nb_investissements > 0 ? s.nb_investissements : <span className="text-muted">—</span>}</td>
<td>
{editingSectInv !== s.id && (
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
<button style={{ fontSize: 12, padding: '2px 8px' }}
onClick={() => { setEditingSectInv(s.id); setEditingNomSectInv(s.nom); setErr(null); }}>
Renommer
</button>
<button style={{ fontSize: 12, padding: '2px 8px', color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => setConfirmDelete({
title: 'Supprimer le secteur',
message: `Supprimer le secteur "${s.nom}" ? Il sera retiré de toutes les plateformes et investissements.`,
confirmLabel: 'Supprimer',
onConfirm: () => { delSectInv(s.id); setConfirmDelete(null); }
})}>
Supprimer
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
export const fmtEUR = (n) =>
(n == null ? '' : Number(n).toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' }));
export const fmtNum = (n, digits = 2) =>
(n == null ? '' : Number(n).toLocaleString('fr-FR', { minimumFractionDigits: digits, maximumFractionDigits: digits }));
export const fmtPct = (n) =>
(n == null ? '' : Number(n).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' %');
export const fmtDate = (s) => {
if (!s) return '';
const [y, m, d] = String(s).slice(0, 10).split('-');
return d ? `${d}/${m}/${y}` : s;
};
export const today = () => new Date().toISOString().slice(0, 10);
/* ── Membres / Investisseurs ───────────────────────────────── */
/** Nom affiché.
* `nom` contient toujours le nom complet ("Olivier CROGUENNEC", "SCI Famille…").
* `prenom` est stocké séparément uniquement pour les initiales et le formulaire.
*/
export const memberDisplayName = (m) => m?.nom ?? '';
/** Label pour les selects : displayName + type_fiscal si entreprise */
export const memberLabel = (m) => {
if (!m) return '';
const name = memberDisplayName(m);
if (m.type === 'entreprise' && m.type_fiscal) return `${name} (${m.type_fiscal})`;
return name;
};
/* ── Statuts investissements ───────────────────────────────── */
export const STATUT_LABELS = {
en_cours: 'En cours',
rembourse: 'Remboursé',
en_retard: 'En retard',
procedure: 'Procédure',
cloture: 'Clôturé',
};
/** Retourne le libellé affiché d'un statut (première lettre majuscule). */
export const fmtStatut = (s) => STATUT_LABELS[s] ?? s;
/** Initiales pour l'avatar (2 caractères max).
* - Si prenom est renseigné : 1re lettre du prénom + 1re lettre du reste du nom complet
* - Sinon (données migrées) : 1re lettre du 1er mot + 1re lettre du dernier mot du nom complet
* Ex. prenom="Olivier" nom="Olivier CROGUENNEC" → "OC"
* Ex. prenom=null nom="Marine CROGUENNEC" → "MC"
*/
export const memberInitials = (m) => {
if (!m) return '?';
if (m.type === 'famille') {
if (m.prenom) {
const firstLetter = m.prenom[0];
const rest = m.nom.replace(m.prenom, '').trim();
const lastLetter = rest[0] || '';
return (firstLetter + lastLetter).toUpperCase();
}
// prenom absent : découper le nom complet en mots
const parts = m.nom.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return m.nom.slice(0, 2).toUpperCase();
}
// Entreprise : deux premières lettres de la raison sociale
return m.nom.slice(0, 2).toUpperCase();
};