Initial commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
npm-debug.log
|
||||
*.log
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.env.local
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
ARG VITE_API_URL=/api
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Crowdlending Tracker</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/flag-icon-css/7.2.3/css/flag-icons.min.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Static assets — long cache
|
||||
location /assets/ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# API proxy to the backend service (docker-compose internal DNS)
|
||||
location /api/ {
|
||||
proxy_pass http://backend:4000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
client_max_body_size 15M;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+1823
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "crowdlending-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||
<!-- Background rounded square -->
|
||||
<rect width="48" height="48" rx="10" fill="#1e3a8a"/>
|
||||
|
||||
<!-- 3 ascending bars (portfolio growth) -->
|
||||
<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"/>
|
||||
|
||||
<!-- Upward arrow above the tallest bar -->
|
||||
<polygon points="37.5,4 44,12 31,12" fill="#4ade80"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 516 B |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
|
||||
/**
|
||||
* CategorySelect — multi-select with checkboxes + inline "Add category"
|
||||
*
|
||||
* Props:
|
||||
* selected : number[] — ids sélectionnés
|
||||
* onChange : (ids: number[]) => void
|
||||
* categories : { id, nom }[] — liste complète fournie par le parent
|
||||
* onCategoryAdded : ({ id, nom }) => void — appelé après création inline
|
||||
*
|
||||
* Le dropdown est rendu en position:fixed (calculé depuis getBoundingClientRect)
|
||||
* pour échapper au overflow:auto des modales parentes.
|
||||
*/
|
||||
export default function CategorySelect({ selected = [], onChange, categories = [], onCategoryAdded }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [err, setErr] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 0 });
|
||||
|
||||
const wrapRef = useRef(null);
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
/* ── Position fixe calculée à chaque ouverture ───────────────── */
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !triggerRef.current) return;
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
setDropPos({
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
});
|
||||
}, [open]);
|
||||
|
||||
/* ── Fermeture : clic extérieur + scroll + resize ────────────── */
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e) => {
|
||||
if (wrapRef.current?.contains(e.target)) return;
|
||||
// Exclure aussi le dropdown lui-même (rendu en fixed hors du wrap)
|
||||
const drop = document.getElementById('cat-select-dropdown-portal');
|
||||
if (drop?.contains(e.target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
const closeOnScroll = (e) => {
|
||||
const drop = document.getElementById('cat-select-dropdown-portal');
|
||||
if (drop?.contains(e.target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', close);
|
||||
window.addEventListener('scroll', closeOnScroll, true);
|
||||
window.addEventListener('resize', closeOnScroll);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', close);
|
||||
window.removeEventListener('scroll', closeOnScroll, true);
|
||||
window.removeEventListener('resize', closeOnScroll);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const toggle = (id) => {
|
||||
onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]);
|
||||
};
|
||||
|
||||
const addCategory = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newName.trim()) return;
|
||||
setBusy(true); setErr(null);
|
||||
try {
|
||||
const cat = await api.post('/categories', { nom: newName.trim() });
|
||||
onCategoryAdded(cat);
|
||||
onChange([...selected, cat.id]);
|
||||
setNewName('');
|
||||
setAdding(false);
|
||||
} catch (e) {
|
||||
setErr(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ── Label du bouton déclencheur ─────────────────────────────── */
|
||||
const label = (() => {
|
||||
if (selected.length === 0) return "Aucune catégorie d'investissement";
|
||||
const names = categories.filter(c => selected.includes(c.id)).map(c => c.nom);
|
||||
if (names.length <= 2) return names.join(', ');
|
||||
return `${names.length} catégories d'invest.`;
|
||||
})();
|
||||
|
||||
/* ── Dropdown rendu en position:fixed ────────────────────────── */
|
||||
const dropdown = open ? (
|
||||
<div
|
||||
id="cat-select-dropdown-portal"
|
||||
className="cat-select-dropdown"
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: dropPos.top,
|
||||
left: dropPos.left,
|
||||
width: dropPos.width,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
{/* Liste des catégories */}
|
||||
{categories.length === 0 && (
|
||||
<div className="cat-select-empty">Aucune catégorie d'investissement disponible</div>
|
||||
)}
|
||||
{categories.map(cat => {
|
||||
const checked = selected.includes(cat.id);
|
||||
return (
|
||||
<label key={cat.id} className={`cat-select-item${checked ? ' checked' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggle(cat.id)}
|
||||
/>
|
||||
<span>{cat.nom}</span>
|
||||
{checked && (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ marginLeft: 'auto', color: 'var(--accent)' }} aria-hidden="true">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Séparateur + ajout */}
|
||||
<div className="cat-select-sep" />
|
||||
|
||||
{!adding ? (
|
||||
<button type="button" className="cat-select-add-btn"
|
||||
onClick={() => { setAdding(true); setErr(null); }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
aria-hidden="true">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
Ajouter une catégorie d'investissement
|
||||
</button>
|
||||
) : (
|
||||
<form onSubmit={addCategory} className="cat-select-new-form">
|
||||
<input
|
||||
autoFocus
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
placeholder="Nom de la catégorie d'investissement"
|
||||
maxLength={100}
|
||||
/>
|
||||
<div className="cat-select-new-actions">
|
||||
<button type="submit" className="primary" disabled={busy || !newName.trim()}>
|
||||
{busy ? '…' : 'Créer'}
|
||||
</button>
|
||||
<button type="button" className="ghost"
|
||||
onClick={() => { setAdding(false); setNewName(''); setErr(null); }}>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
{err && <div className="cat-select-err">{err}</div>}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={wrapRef} className="cat-select-wrap">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={`cat-select-trigger${open ? ' open' : ''}`}
|
||||
onClick={() => { setOpen(o => !o); setAdding(false); setErr(null); }}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="cat-select-label">{label}</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ flexShrink: 0, transition: 'transform .15s', transform: open ? 'rotate(0deg)' : 'rotate(180deg)' }}
|
||||
aria-hidden="true">
|
||||
<path d="M18 15l-6-6-6 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dropdown rendu hors du wrap pour échapper à overflow:auto */}
|
||||
{dropdown}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const MOIS_LABELS = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||||
const MOIS_NUMS = ['01','02','03','04','05','06','07','08','09','10','11','12'];
|
||||
const r2 = v => Math.round((v ?? 0) * 100) / 100;
|
||||
const fmtN = v => Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
const fmtI = v => Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||
const fullName = l => { const n = l.investisseur_nom ?? ''; const p = l.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); };
|
||||
|
||||
const BADGE_AUTO = { text: 'déclaration automatique', bg: 'rgba(22,163,74,0.1)', color: '#16a34a' };
|
||||
const BADGE_DECL = { text: 'à déclarer', bg: 'rgba(239,68,68,0.1)', color: '#dc2626' };
|
||||
|
||||
function BadgeTag({ badge }) {
|
||||
if (!badge) return null;
|
||||
return (
|
||||
<span style={{ fontSize: 10, padding: '1px 6px', borderRadius: 10, background: badge.bg, color: badge.color, fontWeight: 600, whiteSpace: 'nowrap', marginLeft: 6 }}>
|
||||
{badge.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* breakdown item: { nom, val, badge } */
|
||||
function Case2042({ code, label, note, value, breakdown }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '90px 1fr auto', gap: '0 12px', alignItems: 'center', padding: '10px 14px' }}>
|
||||
<div style={{ fontFamily: 'monospace', fontWeight: 800, fontSize: 'var(--fs-base)', color: 'var(--primary)', background: 'rgba(99,102,241,0.07)', borderRadius: 6, padding: '2px 6px', textAlign: 'center', letterSpacing: '.04em' }}>{code}</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 'var(--fs-sm)', fontWeight: 600 }}>{label}</div>
|
||||
{note && <div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 2 }}>{note}</div>}
|
||||
</div>
|
||||
<div style={{ fontWeight: 800, fontSize: 'var(--fs-lg)', whiteSpace: 'nowrap', color: 'var(--text)' }}>{fmtI(value)} €</div>
|
||||
</div>
|
||||
{breakdown && breakdown.length > 0 && (
|
||||
<div style={{ padding: '0 14px 10px 118px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{breakdown.map((p, i) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center' }}>
|
||||
└ {p.nom}
|
||||
<BadgeTag badge={p.badge} />
|
||||
</span>
|
||||
<span style={{ fontWeight: 500, marginLeft: 12 }}>{fmtI(p.val)} €</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Cerfa2042Preview({ annee, activeView, pfoAssujetti }) {
|
||||
const LS_EXCL = 'cl_2778_excluded_plats';
|
||||
|
||||
const [data2561, setData2561] = useState(null);
|
||||
const [data2778, setData2778] = useState(null);
|
||||
const [pfuList, setPfuList] = useState([]);
|
||||
const [excluded, setExcluded] = useState(() => {
|
||||
try { return new Set(JSON.parse(localStorage.getItem(LS_EXCL)) ?? []); }
|
||||
catch { return new Set(); }
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [view, setView] = useState('matrice');
|
||||
const [filterMode, setFilterMode] = useState('all'); // 'all' | 'auto' | 'decl'
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||
Promise.all([
|
||||
api.get('/taxreport/cerfa2561', { annee, ...scopeParams }),
|
||||
pfuList.length === 0 ? api.get('/pfu') : Promise.resolve(null),
|
||||
api.get('/taxreport/2778', { annee, ...scopeParams }),
|
||||
]).then(([d2561, pfu, d2778]) => {
|
||||
setData2561(d2561);
|
||||
if (pfu) setPfuList(pfu);
|
||||
if (d2778) setData2778(d2778);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [annee, activeView]); // eslint-disable-line
|
||||
|
||||
const frLignes = (data2561?.lignes ?? []).filter(l => l.domiciliation === 'FR');
|
||||
const platEtr = (data2778?.plateformes ?? []).filter(p => !excluded.has(p.id));
|
||||
|
||||
const ratesForYear = () => {
|
||||
const sorted = [...pfuList].sort((a, b) => b.annee - a.annee);
|
||||
const m = sorted.find(r => r.annee <= Number(annee));
|
||||
if (!m) return { pfo: 0.128, csg: 0.106, crds: 0.005, solidarite: 0.075 };
|
||||
return { pfo: (m.impot_revenu ?? 12.8) / 100, csg: (m.csg ?? 10.6) / 100, crds: (m.crds ?? 0.5) / 100, solidarite: (m.solidarite ?? 7.5) / 100 };
|
||||
};
|
||||
const rates = ratesForYear();
|
||||
const totalTaxRate = rates.pfo + rates.csg + rates.crds + rates.solidarite;
|
||||
|
||||
/* ── Suivi mensuel combiné ── */
|
||||
const matriceView = (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
{loading && <div className="card text-muted">Chargement…</div>}
|
||||
{!loading && frLignes.length === 0 && platEtr.length === 0 && (
|
||||
<div className="card"><p className="text-muted" style={{ margin: 0 }}>Aucune donnée pour {annee}.</p></div>
|
||||
)}
|
||||
{!loading && (frLignes.length > 0 || platEtr.length > 0) && (
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, padding: '14px 14px 10px' }}>
|
||||
<h4 style={{ margin: 0, fontWeight: 700 }}>Suivi mensuel des intérêts bruts</h4>
|
||||
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Revenus {annee} — toutes plateformes</span>
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--fs-xs)' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--surface-2)', borderBottom: '1px solid var(--border)' }}>
|
||||
<th style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 600, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Plateforme — Détenteur</th>
|
||||
{MOIS_LABELS.map(m => (
|
||||
<th key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 600, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>{m}</th>
|
||||
))}
|
||||
<th style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 600, color: 'var(--text-muted)' }}>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{frLignes.length > 0 && (
|
||||
<tr style={{ background: 'rgba(22,163,74,0.05)' }}>
|
||||
<td colSpan={14} style={{ padding: '4px 12px', fontSize: 'var(--fs-xs)', fontWeight: 600, color: '#16a34a', letterSpacing: '.04em', textTransform: 'uppercase' }}>
|
||||
Plateformes françaises — déclaration automatique
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{frLignes.map(l => {
|
||||
const total = r2(Object.values(l.mois ?? {}).reduce((s, m) => s + (m.interets_bruts ?? 0), 0));
|
||||
return (
|
||||
<tr key={`fr_${l.plateforme_id}_${l.investisseur_id}`} style={{ borderBottom: '1px solid var(--border)', background: 'transparent' }}>
|
||||
<td style={{ padding: '6px 12px', fontWeight: 500, whiteSpace: 'nowrap' }}>
|
||||
{l.plateforme_nom}
|
||||
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>— {fullName(l)}</span>
|
||||
</td>
|
||||
{MOIS_NUMS.map(m => {
|
||||
const v = l.mois?.[m]?.interets_bruts ?? 0;
|
||||
return <td key={m} style={{ padding: '6px 8px', textAlign: 'right', color: v > 0 ? 'var(--text)' : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'}</td>;
|
||||
})}
|
||||
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700 }}>{total > 0 ? fmtN(total) + ' €' : '—'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{frLignes.length > 0 && (
|
||||
<tr style={{ borderTop: '2px solid var(--border)', background: 'rgba(22,163,74,0.06)' }}>
|
||||
<td style={{ padding: '6px 12px', fontWeight: 700, color: '#16a34a' }}>Total Plateformes françaises (brut)</td>
|
||||
{MOIS_NUMS.map(m => {
|
||||
const v = r2(frLignes.reduce((s, l) => s + (l.mois?.[m]?.interets_bruts ?? 0), 0));
|
||||
return <td key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 700, color: v > 0 ? '#16a34a' : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'}</td>;
|
||||
})}
|
||||
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700, color: '#16a34a' }}>
|
||||
{fmtN(r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + (m.interets_bruts ?? 0), 0)), 0)))} €
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{platEtr.length > 0 && (
|
||||
<tr style={{ background: 'rgba(239,68,68,0.05)' }}>
|
||||
<td colSpan={14} style={{ padding: '4px 12px', fontSize: 'var(--fs-xs)', fontWeight: 600, color: '#dc2626', letterSpacing: '.04em', textTransform: 'uppercase' }}>
|
||||
Plateformes étrangères — à déclarer
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{platEtr.map(p => {
|
||||
const total = r2(Object.values(p.mois ?? {}).reduce((s, v) => s + v, 0));
|
||||
return (
|
||||
<tr key={`etr_${p.id}`} style={{ borderBottom: '1px solid var(--border)', background: 'transparent' }}>
|
||||
<td style={{ padding: '6px 12px', fontWeight: 500, whiteSpace: 'nowrap' }}>
|
||||
{p.nom}
|
||||
{(p.investisseur_nom || p.investisseur_prenom) && (
|
||||
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>— {fullName(p)}</span>
|
||||
)}
|
||||
</td>
|
||||
{MOIS_NUMS.map(m => {
|
||||
const v = p.mois?.[m] ?? 0;
|
||||
return <td key={m} style={{ padding: '6px 8px', textAlign: 'right', color: v > 0 ? 'var(--text)' : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'}</td>;
|
||||
})}
|
||||
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700 }}>{total > 0 ? fmtN(total) + ' €' : '—'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
{platEtr.length > 0 && (
|
||||
<tr style={{ borderTop: '2px solid var(--border)', background: 'rgba(239,68,68,0.06)' }}>
|
||||
<td style={{ padding: '6px 12px', fontWeight: 700, color: '#dc2626' }}>Total Plateformes étrangères (brut)</td>
|
||||
{MOIS_NUMS.map(m => {
|
||||
const v = r2(platEtr.reduce((s, p) => s + (p.mois?.[m] ?? 0), 0));
|
||||
return <td key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 700, color: v > 0 ? '#dc2626' : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'}</td>;
|
||||
})}
|
||||
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700, color: '#dc2626' }}>
|
||||
{fmtN(r2(platEtr.reduce((s, p) => s + r2(Object.values(p.mois ?? {}).reduce((ss, v) => ss + v, 0)), 0)))} €
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr style={{ borderTop: '2px solid var(--border)', background: 'var(--surface-2)' }}>
|
||||
<td style={{ padding: '6px 12px', fontWeight: 700 }}>Total général (brut)</td>
|
||||
{MOIS_NUMS.map(m => {
|
||||
const v = r2(frLignes.reduce((s, l) => s + (l.mois?.[m]?.interets_bruts ?? 0), 0) + platEtr.reduce((s, p) => s + (p.mois?.[m] ?? 0), 0));
|
||||
return <td key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 700, color: v > 0 ? 'var(--text)' : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'}</td>;
|
||||
})}
|
||||
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700 }}>
|
||||
{fmtN(r2(
|
||||
frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + (m.interets_bruts ?? 0), 0)), 0) +
|
||||
platEtr.reduce((s, p) => s + r2(Object.values(p.mois ?? {}).reduce((ss, v) => ss + v, 0)), 0)
|
||||
))} €
|
||||
</td>
|
||||
</tr>
|
||||
{/* Taux total prélevé */}
|
||||
<tr style={{ background: 'transparent' }}>
|
||||
<td style={{ padding: '4px 12px', color: 'var(--text-muted)', fontSize: 'var(--fs-xs)' }}>
|
||||
Taux total prélevé ({(totalTaxRate * 100).toFixed(1)} %)
|
||||
</td>
|
||||
{MOIS_NUMS.map(m => {
|
||||
const brut = r2(frLignes.reduce((s, l) => s + (l.mois?.[m]?.interets_bruts ?? 0), 0) + platEtr.reduce((s, p) => s + (p.mois?.[m] ?? 0), 0));
|
||||
const prelevFR = r2(frLignes.reduce((s, l) => s + ((l.mois?.[m]?.prelev_sociaux ?? 0) + (l.mois?.[m]?.prelev_forfaitaire ?? 0)), 0));
|
||||
const prelevEtr = r2(platEtr.reduce((s, p) => s + (p.mois?.[m] ?? 0), 0) * totalTaxRate);
|
||||
const taux = brut > 0 ? ((prelevFR + prelevEtr) / brut * 100).toFixed(1) + ' %' : '—';
|
||||
return <td key={m} style={{ padding: '4px 8px', textAlign: 'right', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>{taux}</td>;
|
||||
})}
|
||||
<td style={{ padding: '4px 12px', textAlign: 'right', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
|
||||
{(totalTaxRate * 100).toFixed(1)} %
|
||||
</td>
|
||||
</tr>
|
||||
{/* Total intérêt net */}
|
||||
<tr style={{ background: 'rgba(22,163,74,0.06)', borderTop: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '6px 12px', fontWeight: 700, color: 'var(--success)' }}>Total intérêt net</td>
|
||||
{MOIS_NUMS.map(m => {
|
||||
const netFR = r2(frLignes.reduce((s, l) => {
|
||||
const mo = l.mois?.[m];
|
||||
return s + ((mo?.interets_bruts ?? 0) - (mo?.prelev_sociaux ?? 0) - (mo?.prelev_forfaitaire ?? 0));
|
||||
}, 0));
|
||||
const netEtr = r2(platEtr.reduce((s, p) => s + (p.mois?.[m] ?? 0), 0) * (1 - totalTaxRate));
|
||||
const net = r2(netFR + netEtr);
|
||||
return <td key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 700, color: net > 0 ? 'var(--success)' : 'var(--text-muted)' }}>{net > 0 ? fmtN(net) : '—'}</td>;
|
||||
})}
|
||||
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700, color: 'var(--success)' }}>
|
||||
{fmtN(r2(
|
||||
frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + ((m.interets_bruts ?? 0) - (m.prelev_sociaux ?? 0) - (m.prelev_forfaitaire ?? 0)), 0)), 0) +
|
||||
platEtr.reduce((s, p) => s + r2(Object.values(p.mois ?? {}).reduce((ss, v) => ss + v, 0)) * (1 - totalTaxRate), 0)
|
||||
))} €
|
||||
</td>
|
||||
</tr>
|
||||
<tr style={{ background: 'var(--surface-2)' }}>
|
||||
<td colSpan={14} style={{ padding: '8px 12px', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', borderTop: '1px solid var(--border)' }}>
|
||||
Plateformes françaises : prélèvements à la source (taux {(totalTaxRate * 100).toFixed(1)} %) — déclaration automatique sur 2042 · Plateformes étrangères : à déclarer
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ── Données 2042 — cases mixtes par code ── */
|
||||
const données2042View = (() => {
|
||||
if (loading) return <div className="card text-muted">Chargement…</div>;
|
||||
|
||||
// Totaux FR
|
||||
const fr2TT = frLignes.reduce((s, l) => s + (l.case_2TT ?? 0), 0);
|
||||
const fr2TR = frLignes.reduce((s, l) => s + (l.case_2TR ?? 0), 0);
|
||||
const fr2BH = frLignes.reduce((s, l) => s + (l.case_2BH ?? 0), 0);
|
||||
const fr2CK = frLignes.reduce((s, l) => s + (l.case_2CK ?? 0), 0);
|
||||
const fr2TY = frLignes.reduce((s, l) => s + (l.case_2TY ?? 0), 0);
|
||||
|
||||
// Totaux étrangers
|
||||
const etrBA = p => r2(Object.values(p.mois ?? {}).reduce((s, v) => s + v, 0));
|
||||
const applyFilter = items => filterMode === 'all' ? items : items.filter(i => (filterMode === 'auto' ? i.badge === BADGE_AUTO : i.badge === BADGE_DECL));
|
||||
const totalEtrBA = r2(platEtr.reduce((s, p) => s + etrBA(p), 0));
|
||||
const totalEtrIA = Math.round(totalEtrBA * rates.pfo);
|
||||
|
||||
// Breakdown 2TT : FR seulement
|
||||
const bd2TT = applyFilter(frLignes.filter(l => l.case_2TT > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2TT, badge: BADGE_AUTO })));
|
||||
const total2TT = bd2TT.reduce((s, i) => s + i.val, 0);
|
||||
|
||||
// Breakdown 2TR : FR (case_2TR) + étrangères
|
||||
const bd2TR = applyFilter([
|
||||
...frLignes.filter(l => l.case_2TR > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2TR, badge: BADGE_AUTO })),
|
||||
...platEtr.filter(p => etrBA(p) > 0).map(p => ({ nom: p.nom, val: Math.round(etrBA(p)), badge: BADGE_DECL })),
|
||||
]);
|
||||
const total2TR = bd2TR.reduce((s, i) => s + i.val, 0);
|
||||
|
||||
// Breakdown 2BH : FR + étrangères
|
||||
const bd2BH = applyFilter([
|
||||
...frLignes.filter(l => l.case_2BH > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2BH, badge: BADGE_AUTO })),
|
||||
...platEtr.filter(p => etrBA(p) > 0).map(p => ({ nom: p.nom, val: Math.round(etrBA(p)), badge: BADGE_DECL })),
|
||||
]);
|
||||
const total2BH = bd2BH.reduce((s, i) => s + i.val, 0);
|
||||
|
||||
// Breakdown 2CK : FR (PFNL retenu) + étrangères (acompte 2778-SD)
|
||||
const bd2CK = applyFilter([
|
||||
...frLignes.filter(l => l.case_2CK > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2CK, badge: BADGE_AUTO })),
|
||||
...platEtr.filter(p => etrBA(p) > 0).map(p => ({ nom: p.nom, val: Math.round(etrBA(p) * rates.pfo), badge: BADGE_DECL })),
|
||||
]);
|
||||
const total2CK = bd2CK.reduce((s, i) => s + i.val, 0);
|
||||
|
||||
// Breakdown 2TY : FR seulement
|
||||
const bd2TY = applyFilter(frLignes.filter(l => l.case_2TY > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2TY, badge: BADGE_AUTO })));
|
||||
const total2TY = bd2TY.reduce((s, i) => s + i.val, 0);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||||
<h4 style={{ margin: 0, fontWeight: 700 }}>Report annuel — Déclaration 2042</h4>
|
||||
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Revenus {annee} à reporter sur la 2042 déposée en {Number(annee) + 1}</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<div style={{ display: 'flex', gap: 2, background: 'var(--surface-2)', borderRadius: 8, padding: 2, border: '1px solid var(--border)' }}>
|
||||
{[['all','Tout'],['auto','Automatique'],['decl','À déclarer']].map(([val, label]) => (
|
||||
<button key={val} onClick={() => setFilterMode(val)} style={{ padding: '4px 10px', borderRadius: 6, border: 'none', cursor: 'pointer', fontSize: 'var(--fs-xs)', fontWeight: 600, background: filterMode === val ? 'var(--primary)' : 'transparent', color: filterMode === val ? '#fff' : 'var(--text-muted)', transition: 'all .15s' }}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden', marginBottom: 14 }}>
|
||||
<Case2042 code="2TT" label="Produits des prêts participatifs" note="Intérêts bruts — plateformes françaises (financement participatif, case KR)" value={total2TT || undefined} breakdown={bd2TT} />
|
||||
<Case2042 code="2TR" label="Produits de placement à revenu fixe" note="Intérêts bruts — plateformes françaises (case AR) + étrangères (base imposable BA)" value={total2TR || undefined} breakdown={bd2TR} />
|
||||
<Case2042 code="2BH" label="Produits pour lesquels les PS ont déjà été appliqués" note="Même montant que 2TT/2TR — neutralise la double imposition aux prélèvements sociaux" value={total2BH || undefined} breakdown={bd2BH} />
|
||||
<Case2042 code="2CK" label="Crédit d'impôt — prélèvement forfaitaire déjà retenu" note={`FR : PFNL retenu à la source (12,8 %) · Étranger : acompte versé via 2778-SD (${(rates.pfo*100).toFixed(1)} %)`} value={total2CK || undefined} breakdown={bd2CK} />
|
||||
<Case2042 code="2TY" label="Pertes en capital sur prêts participatifs" note="Capital non remboursé sur prêts en défaut — plateformes françaises" value={total2TY || undefined} breakdown={bd2TY} />
|
||||
</div>
|
||||
<div style={{ padding: '10px 14px', borderRadius: 8, background: 'rgba(99,102,241,0.05)', border: '1px solid rgba(99,102,241,0.15)', fontSize: 'var(--fs-xs)', lineHeight: 1.6, color: 'var(--text-muted)' }}>
|
||||
<strong style={{ color: 'var(--text)', display: 'block', marginBottom: 4 }}>Synthèse fiscale</strong>
|
||||
Les montants <BadgeTag badge={BADGE_AUTO} /> sont automatiquement reportés par la plateforme (IFU).
|
||||
Les montants <BadgeTag badge={BADGE_DECL} /> nécessitent une déclaration mensuelle 2778-SD et un report manuel sur la 2042.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', gap: 0, alignItems: 'flex-start' }}>
|
||||
<nav style={{ width: 200, flexShrink: 0, marginRight: 20, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '8px 0', boxSizing: 'border-box' }}>
|
||||
<div style={{ padding: '6px 12px 8px', fontSize: 'var(--fs-xs)', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.06em' }}>Sections</div>
|
||||
<button className={`account-nav-item${view === 'matrice' ? ' active' : ''}`} onClick={() => setView('matrice')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 3h18v2H3V3zm0 7h12v2H3v-2zm0 7h18v2H3v-2z"/></svg>
|
||||
Suivi mensuel
|
||||
</button>
|
||||
<button className={`account-nav-item${view === 'donnees' ? ' active' : ''}`} onClick={() => setView('donnees')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
Données 2042
|
||||
</button>
|
||||
</nav>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{view === 'matrice' && matriceView}
|
||||
{view === 'donnees' && données2042View}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
|
||||
const fmtEUR = n => {
|
||||
if (!n && n !== 0) return '';
|
||||
return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(n);
|
||||
};
|
||||
|
||||
const fmtEURDec = n => {
|
||||
if (!n && n !== 0) return '';
|
||||
return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
|
||||
};
|
||||
|
||||
/* ── Cellule du formulaire ── */
|
||||
/* Layout : [code2561] [label + report 2042] [code2042 noir] [montant] */
|
||||
function Cell({ label, code2561, code2042, value, filled, noReport }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'stretch',
|
||||
border: '1px solid #aaa',
|
||||
marginBottom: -1,
|
||||
background: filled ? '#fffbe6' : '#fff',
|
||||
}}>
|
||||
{/* Case 1 — code 2561 (ex: KR) */}
|
||||
<div style={{
|
||||
width: 44, flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
borderRight: '1px solid #aaa',
|
||||
background: '#f5f3ff',
|
||||
fontSize: 11, fontWeight: 700, color: '#7c3aed',
|
||||
}}>{code2561}</div>
|
||||
|
||||
{/* Case 2 — désignation + report */}
|
||||
<div style={{
|
||||
flex: 1, padding: '5px 8px',
|
||||
fontSize: 11, color: '#222', lineHeight: 1.4,
|
||||
borderRight: '1px solid #aaa',
|
||||
}}>
|
||||
<div>{label}</div>
|
||||
<div style={{ fontSize: 10, color: noReport ? '#9a3412' : '#555', marginTop: 2 }}>
|
||||
{noReport ? 'Sans report sur la déclaration 2042' : 'Montant à reporter sur votre déclaration 2042'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Case 3 — code 2042 (ex: 2TT) blanc sur noir */}
|
||||
<div style={{
|
||||
width: 48, flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
borderRight: '1px solid #aaa',
|
||||
background: noReport ? '#78716c' : (filled ? '#111' : '#555'),
|
||||
fontSize: 11, fontWeight: 700, color: '#fff',
|
||||
}}>{code2042 ?? '—'}</div>
|
||||
|
||||
{/* Case 4 — montant */}
|
||||
<div style={{
|
||||
width: 90, flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'flex-end',
|
||||
padding: '0 10px',
|
||||
fontSize: 12, fontWeight: filled ? 700 : 400,
|
||||
color: filled ? '#14532d' : '#bbb',
|
||||
background: filled ? '#f0fdf4' : '#fafafa',
|
||||
}}>
|
||||
{filled ? fmtEUR(value) + ' €' : '—'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTitle({ children }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: '#5b21b6', color: '#fff',
|
||||
WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact',
|
||||
fontWeight: 700, fontSize: 11, textTransform: 'uppercase',
|
||||
letterSpacing: '.04em', padding: '4px 8px',
|
||||
marginTop: 10, marginBottom: 0,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldRow({ label, code, value }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', border: '1px solid #aaa', marginBottom: -1 }}>
|
||||
<div style={{ width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', textAlign: 'center', padding: '3px 4px', fontSize: 10, fontWeight: 600, color: '#7c3aed', borderRight: '1px solid #aaa', background: '#f5f3ff' }}>{code}</div>
|
||||
<div style={{ width: 120, flexShrink: 0, display: 'flex', alignItems: 'center', padding: '3px 6px', fontSize: 11, color: '#222', borderRight: '1px solid #aaa' }}>{label}</div>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', padding: '3px 8px', fontSize: 11, color: value ? '#111' : 'transparent', background: '#fff' }}>{value || ''}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Cellule éditable en place ── */
|
||||
function EditableFieldRow({ label, code, value, onSave }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value ?? '');
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const startEdit = () => { setDraft(value ?? ''); setEditing(true); };
|
||||
const confirm = () => { onSave(draft.trim() || null); setEditing(false); };
|
||||
const onKey = e => { if (e.key === 'Enter') confirm(); if (e.key === 'Escape') setEditing(false); };
|
||||
|
||||
useEffect(() => { if (editing && inputRef.current) inputRef.current.focus(); }, [editing]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', border: '1px solid #aaa', marginBottom: -1 }}>
|
||||
<div style={{ width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3px 4px', fontSize: 10, fontWeight: 600, color: '#7c3aed', borderRight: '1px solid #aaa', background: '#f5f3ff' }}>{code}</div>
|
||||
<div style={{ width: 120, flexShrink: 0, display: 'flex', alignItems: 'center', padding: '3px 6px', fontSize: 11, color: '#222', borderRight: '1px solid #aaa' }}>{label}</div>
|
||||
<div
|
||||
onClick={!editing ? startEdit : undefined}
|
||||
style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center',
|
||||
background: editing ? '#f5f3ff' : '#fff',
|
||||
cursor: editing ? 'default' : 'text',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{editing ? (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
style={{
|
||||
flex: 1, border: 'none', outline: 'none', background: 'transparent',
|
||||
fontSize: 11, padding: '3px 8px', color: '#111',
|
||||
}}
|
||||
placeholder="Saisir…"
|
||||
/>
|
||||
<button
|
||||
onClick={confirm}
|
||||
title="Valider"
|
||||
style={{
|
||||
flexShrink: 0, border: 'none', background: 'none', cursor: 'pointer',
|
||||
padding: '0 8px', color: '#7c3aed', fontSize: 14, fontWeight: 700,
|
||||
}}
|
||||
>✓</button>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ padding: '3px 8px', fontSize: 11, color: value ? '#111' : '#bbb', fontStyle: value ? 'normal' : 'italic' }}>
|
||||
{value || 'Cliquer pour saisir'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Formulaire d'une ligne (plateforme × investisseur) ── */
|
||||
function Cerfa2561Form({ ligne, index, total }) {
|
||||
const flatTax = ligne.domiciliation === 'FR' && ligne.fiscalite === 'flat_tax';
|
||||
const use2TR = ligne.type_produit_fiscal === '2TR';
|
||||
const hasPertes = ligne.case_2TY > 0;
|
||||
|
||||
const [taxDetails, setTaxDetails] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.get(`/plateforme-tax/${ligne.plateforme_id}/${ligne.annee}`)
|
||||
.then(setTaxDetails)
|
||||
.catch(() => setTaxDetails({ raison_sociale: ligne.plateforme_nom, siret_n: null, siret_n1: null }));
|
||||
}, [ligne.plateforme_id, ligne.annee]); // eslint-disable-line
|
||||
|
||||
const save = (field) => (val) => {
|
||||
if (!taxDetails) return;
|
||||
const updated = { ...taxDetails, [field]: val };
|
||||
setTaxDetails(updated);
|
||||
api.patch(`/plateforme-tax/${taxDetails.id}`, { [field]: val });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
fontFamily: 'Arial, sans-serif',
|
||||
maxWidth: 900, margin: '0 auto 40px',
|
||||
border: '2px solid #5b21b6',
|
||||
pageBreakAfter: index < total - 1 ? 'always' : 'auto',
|
||||
colorScheme: 'light',
|
||||
background: '#fff',
|
||||
color: '#111',
|
||||
}}>
|
||||
{/* En-tête */}
|
||||
<div style={{ background: '#5b21b6', color: '#fff', padding: '10px 14px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, letterSpacing: '.05em' }}>CERFA N°2561 — N°11428*27</div>
|
||||
<div style={{ fontSize: 11, marginTop: 2, opacity: .85 }}>Déclaration récapitulative des opérations sur valeurs mobilières et revenus de capitaux mobiliers</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 22, fontWeight: 700 }}>{ligne.annee}</div>
|
||||
<div style={{ fontSize: 10, opacity: .8 }}>Simulation — non officielle</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 10 }}>
|
||||
{/* Avertissement */}
|
||||
<div style={{ background: '#fff8e1', border: '1px solid #f59e0b', borderRadius: 4, padding: '6px 10px', fontSize: 10, color: '#92400e', marginBottom: 10 }}>
|
||||
⚠ Ce document est une simulation générée par votre outil de suivi. Il ne remplace pas le formulaire officiel émis par la plateforme.
|
||||
Vérifiez les informations auprès de {ligne.plateforme_nom} avant toute déclaration.
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
{/* Colonne gauche */}
|
||||
<div>
|
||||
<SectionTitle>Désignation du payeur (plateforme)</SectionTitle>
|
||||
<EditableFieldRow label="Raison sociale" code="ZM"
|
||||
value={taxDetails?.raison_sociale ?? ligne.plateforme_nom}
|
||||
onSave={save('raison_sociale')} />
|
||||
<EditableFieldRow label={`N° SIRET au 31-12-${Number(ligne.annee) - 1}`} code="ZT"
|
||||
value={taxDetails?.siret_n1 ?? null}
|
||||
onSave={save('siret_n1')} />
|
||||
<EditableFieldRow label={`N° SIRET au 31-12-${ligne.annee}`} code="ZS"
|
||||
value={taxDetails?.siret_n ?? null}
|
||||
onSave={save('siret_n')} />
|
||||
|
||||
<SectionTitle>Désignation du bénéficiaire (investisseur)</SectionTitle>
|
||||
<FieldRow label="Nom de famille" code="ZC" value={ligne.investisseur_nom} />
|
||||
<FieldRow label="Prénoms" code="ZD" value={ligne.investisseur_prenom} />
|
||||
</div>
|
||||
|
||||
{/* Colonne droite — infos générales */}
|
||||
<div>
|
||||
<SectionTitle>Informations générales</SectionTitle>
|
||||
<FieldRow label="Période de référence" code="AQ" value={`0101 – 1231`} />
|
||||
<FieldRow label="Année fiscale" code="—" value={ligne.annee} />
|
||||
|
||||
<SectionTitle>Récapitulatif fiscal</SectionTitle>
|
||||
<div style={{ border: '1px solid #aaa', padding: '6px 8px', background: '#f8faff', fontSize: 11 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||
<span>Intérêts bruts</span><strong>{fmtEURDec(ligne.interets_bruts)} €</strong>
|
||||
</div>
|
||||
{flatTax && <>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3, color: '#dc2626' }}>
|
||||
<span>− Prélèvements sociaux</span><span>−{fmtEURDec(ligne.prelev_sociaux)} €</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3, color: '#dc2626' }}>
|
||||
<span>− PFNL (12,8%)</span><span>−{fmtEURDec(ligne.prelev_forfaitaire)} €</span>
|
||||
</div>
|
||||
</>}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', borderTop: '1px solid #ccc', paddingTop: 3, fontWeight: 700 }}>
|
||||
<span>Intérêts nets</span><span>{fmtEURDec(ligne.interets_nets)} €</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 4, fontSize: 10, color: '#666' }}>
|
||||
{flatTax
|
||||
? '✓ Plateforme française — PS et PFNL déjà prélevés à la source'
|
||||
: '○ Plateforme étrangère — PS non prélevés, à régulariser'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cases fiscales */}
|
||||
<SectionTitle>Cases à remplir sur le formulaire 2561</SectionTitle>
|
||||
|
||||
{/* Section produits — 2TT ou 2TR selon paramétrage plateforme */}
|
||||
{use2TR ? (
|
||||
<>
|
||||
<div style={{ background: '#ede9fe', padding: '4px 8px', fontSize: 10, fontWeight: 700, color: '#6d28d9', borderBottom: '1px solid #aaa', marginTop: 0 }}>
|
||||
PRODUITS DE PLACEMENT À REVENU FIXE
|
||||
</div>
|
||||
<Cell
|
||||
label="Gains — produits de placement à revenu fixe"
|
||||
code2561="AR" code2042="2TR" value={ligne.case_2TR} filled={ligne.case_2TR > 0}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ background: '#ede9fe', padding: '4px 8px', fontSize: 10, fontWeight: 700, color: '#6d28d9', borderBottom: '1px solid #aaa', marginTop: 0 }}>
|
||||
PRODUITS DES MINIBONS ET DES PRÊTS DANS LE CADRE DU FINANCEMENT PARTICIPATIF
|
||||
</div>
|
||||
<Cell
|
||||
label="Produits des prêts dans le cadre du financement participatif"
|
||||
code2561="KR" code2042="2TT" value={ligne.case_2TT} filled={ligne.case_2TT > 0}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{use2TR ? (
|
||||
<Cell
|
||||
label="Pertes — produits de placement à revenu fixe"
|
||||
code2561="AS" code2042={null} value={ligne.case_2TY} filled={ligne.case_2TY > 0}
|
||||
noReport
|
||||
/>
|
||||
) : (
|
||||
<Cell
|
||||
label="Pertes sur prêts dans le cadre du financement participatif"
|
||||
code2561="KS" code2042="2TY" value={ligne.case_2TY} filled={ligne.case_2TY > 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Section 2BH — si PS ont été prélevés */}
|
||||
{ligne.case_2BH > 0 && <>
|
||||
<div style={{ background: '#ede9fe', padding: '4px 8px', fontSize: 10, fontWeight: 700, color: '#5b21b6', borderBottom: '1px solid #aaa', marginTop: 6 }}>
|
||||
PRODUITS POUR LESQUELS LES PRÉLÈVEMENTS SOCIAUX ONT DÉJÀ ÉTÉ APPLIQUÉS
|
||||
</div>
|
||||
<Cell
|
||||
label="Produits susceptibles d'ouvrir droit à CSG déductible en cas d'option pour le barème progressif"
|
||||
code2561="DQ" code2042="2BH" value={ligne.case_2BH} filled={ligne.case_2BH > 0}
|
||||
/>
|
||||
</>}
|
||||
|
||||
{/* Section 2CK — flat-tax FR uniquement */}
|
||||
{flatTax && ligne.case_2CK > 0 && <>
|
||||
<div style={{ background: '#fef9c3', padding: '4px 8px', fontSize: 10, fontWeight: 700, color: '#854d0e', borderBottom: '1px solid #aaa', marginTop: 6 }}>
|
||||
CRÉDIT D'IMPÔT PRÉLÈVEMENT
|
||||
</div>
|
||||
<Cell
|
||||
label="Crédit d'impôt prélèvement — PFNL déjà versé (12,8%)"
|
||||
code2561="AD" code2042="2CK" value={ligne.case_2CK} filled={ligne.case_2CK > 0}
|
||||
/>
|
||||
</>}
|
||||
|
||||
{/* Nombre d'opérations — cliquable */}
|
||||
<RembDetail ligne={ligne} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Panneau remboursements dépliable ── */
|
||||
function RembDetail({ ligne }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [rows, setRows] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { activeView } = useInvestisseur();
|
||||
|
||||
const load = () => {
|
||||
if (rows !== null) { setOpen(o => !o); return; }
|
||||
setLoading(true);
|
||||
const params = {
|
||||
annee: ligne.annee,
|
||||
plateforme_id: ligne.plateforme_id,
|
||||
investisseur_id: ligne.investisseur_id,
|
||||
...(activeView === 'all' ? { scope: 'all' } : {}),
|
||||
};
|
||||
api.get('/taxreport/cerfa2561/remboursements', params)
|
||||
.then(data => { setRows(data); setOpen(true); })
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const fmtDate = d => d ? d.slice(0, 10) : '—';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={load}
|
||||
style={{
|
||||
marginTop: 8, fontSize: 10, textAlign: 'right',
|
||||
color: '#7c3aed', cursor: 'pointer', userSelect: 'none',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 4,
|
||||
}}
|
||||
>
|
||||
{loading ? 'Chargement…' : (
|
||||
<>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
|
||||
strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s', flexShrink: 0 }}>
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
{ligne.nb_remboursements} remboursement{ligne.nb_remboursements > 1 ? 's' : ''} pris en compte
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && rows && (
|
||||
<div className="no-print" style={{ marginTop: 6, border: '1px solid #ccc', borderRadius: 4, overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 10 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#5b21b6' }}>
|
||||
{[['Date','left'],['Projet','left'],['Capital','right'],['Intérêts bruts','right'],['Prélèv. sociaux','right'],['PFNL (2CK)','right'],['Intérêts nets','right']].map(([label, align]) => (
|
||||
<th key={label} style={{ padding: '4px 8px', textAlign: align, fontWeight: 600, color: '#fff' }}>{label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={r.id} style={{ background: i % 2 === 0 ? '#fff' : '#f8faff', borderBottom: '1px solid #e5e7eb' }}>
|
||||
<td style={{ padding: '3px 8px', whiteSpace: 'nowrap' }}>{fmtDate(r.date_remb)}</td>
|
||||
<td style={{ padding: '3px 8px', color: '#444' }}>{r.nom_projet}</td>
|
||||
<td style={{ padding: '3px 8px', textAlign: 'right' }}>{fmtEURDec(r.capital)} €</td>
|
||||
<td style={{ padding: '3px 8px', textAlign: 'right', fontWeight: 600 }}>{fmtEURDec(r.interets_bruts)} €</td>
|
||||
<td style={{ padding: '3px 8px', textAlign: 'right', color: '#dc2626' }}>{r.prelev_sociaux ? `−${fmtEURDec(r.prelev_sociaux)} €` : '—'}</td>
|
||||
<td style={{ padding: '3px 8px', textAlign: 'right', color: '#dc2626' }}>{r.prelev_forfaitaire ? `−${fmtEURDec(r.prelev_forfaitaire)} €` : '—'}</td>
|
||||
<td style={{ padding: '3px 8px', textAlign: 'right', fontWeight: 600, color: '#14532d' }}>{fmtEURDec(r.interets_nets)} €</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr style={{ background: '#f5f3ff', fontWeight: 700, borderTop: '2px solid #5b21b6' }}>
|
||||
<td colSpan={3} style={{ padding: '4px 8px', fontSize: 10 }}>Total</td>
|
||||
<td style={{ padding: '4px 8px', textAlign: 'right' }}>{fmtEURDec(rows.reduce((s, r) => s + r.interets_bruts, 0))} €</td>
|
||||
<td style={{ padding: '4px 8px', textAlign: 'right', color: '#dc2626' }}>−{fmtEURDec(rows.reduce((s, r) => s + r.prelev_sociaux, 0))} €</td>
|
||||
<td style={{ padding: '4px 8px', textAlign: 'right', color: '#dc2626' }}>−{fmtEURDec(rows.reduce((s, r) => s + r.prelev_forfaitaire, 0))} €</td>
|
||||
<td style={{ padding: '4px 8px', textAlign: 'right', color: '#14532d' }}>{fmtEURDec(rows.reduce((s, r) => s + r.interets_nets, 0))} €</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const MOIS_LABELS = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||||
const MOIS_NUMS = ['01','02','03','04','05','06','07','08','09','10','11','12'];
|
||||
const r2 = v => Math.round((v ?? 0) * 100) / 100;
|
||||
|
||||
/* ── Report 2042 pour plateformes françaises ── */
|
||||
function Report2042Block2561({ lignes }) {
|
||||
const frLignes = (lignes ?? []).filter(l => l.domiciliation === 'FR');
|
||||
if (frLignes.length === 0) return (
|
||||
<div className="card"><p className="text-muted" style={{ margin: 0 }}>Aucune plateforme française avec des remboursements.</p></div>
|
||||
);
|
||||
|
||||
const total2TT = frLignes.reduce((s, l) => s + (l.case_2TT ?? 0), 0);
|
||||
const total2TR = frLignes.reduce((s, l) => s + (l.case_2TR ?? 0), 0);
|
||||
const total2BH = frLignes.reduce((s, l) => s + (l.case_2BH ?? 0), 0);
|
||||
const total2CK = frLignes.reduce((s, l) => s + (l.case_2CK ?? 0), 0);
|
||||
const total2TY = frLignes.reduce((s, l) => s + (l.case_2TY ?? 0), 0);
|
||||
|
||||
const fmt = v => Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||
const fullName = l => { const n = l.investisseur_nom ?? ''; const p = l.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); };
|
||||
|
||||
const Case2042 = ({ code, label, note, value, breakdown }) => {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '90px 1fr auto', gap: '0 12px', alignItems: 'center', padding: '10px 14px' }}>
|
||||
<div style={{ fontFamily: 'monospace', fontWeight: 800, fontSize: 'var(--fs-base)', color: 'var(--primary)', background: 'rgba(99,102,241,0.07)', borderRadius: 6, padding: '2px 6px', textAlign: 'center', letterSpacing: '.04em' }}>{code}</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 'var(--fs-sm)', fontWeight: 600 }}>{label}</div>
|
||||
{note && <div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 2 }}>{note}</div>}
|
||||
</div>
|
||||
<div style={{ fontWeight: 800, fontSize: 'var(--fs-lg)', whiteSpace: 'nowrap', color: 'var(--text)' }}>{fmt(value)} €</div>
|
||||
</div>
|
||||
{breakdown && breakdown.length > 0 && (
|
||||
<div style={{ padding: '0 14px 10px 118px', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{breakdown.map(p => (
|
||||
<div key={p.nom} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
|
||||
<span>└ {p.nom}</span>
|
||||
<span style={{ fontWeight: 500 }}>{fmt(p.val)} €</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const annee = frLignes[0]?.annee;
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 14 }}>
|
||||
<h4 style={{ margin: 0, fontWeight: 700 }}>Report annuel — Déclaration 2042</h4>
|
||||
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Revenus {annee} à reporter sur la 2042 déposée en {Number(annee) + 1}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden', marginBottom: 14 }}>
|
||||
<Case2042
|
||||
code="2TT"
|
||||
label="Produits des prêts participatifs (financement participatif)"
|
||||
note="Intérêts bruts — plateformes françaises avec case KR (2TT)"
|
||||
value={total2TT}
|
||||
breakdown={frLignes.filter(l => l.case_2TT > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2TT }))}
|
||||
/>
|
||||
<Case2042
|
||||
code="2TR"
|
||||
label="Produits de placement à revenu fixe"
|
||||
note="Intérêts bruts — plateformes françaises avec case AR (2TR)"
|
||||
value={total2TR}
|
||||
breakdown={frLignes.filter(l => l.case_2TR > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2TR }))}
|
||||
/>
|
||||
<Case2042
|
||||
code="2BH"
|
||||
label="Produits pour lesquels les prélèvements sociaux ont déjà été appliqués"
|
||||
note="Même montant que 2TT/2TR — évite la double imposition aux prélèvements sociaux"
|
||||
value={total2BH}
|
||||
breakdown={frLignes.filter(l => l.case_2BH > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2BH }))}
|
||||
/>
|
||||
<Case2042
|
||||
code="2CK"
|
||||
label="Crédit d'impôt — PFNL déjà versé (12,8 %)"
|
||||
note="Prélèvement forfaitaire non libératoire déjà retenu à la source — s'impute sur l'IR définitif"
|
||||
value={total2CK}
|
||||
breakdown={frLignes.filter(l => l.case_2CK > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2CK }))}
|
||||
/>
|
||||
<Case2042
|
||||
code="2TY"
|
||||
label="Pertes en capital sur prêts participatifs"
|
||||
note="Capital non remboursé sur prêts en défaut ou clôturés"
|
||||
value={total2TY}
|
||||
breakdown={frLignes.filter(l => l.case_2TY > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2TY }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '10px 14px', borderRadius: 8, background: 'rgba(99,102,241,0.05)', border: '1px solid rgba(99,102,241,0.15)', fontSize: 'var(--fs-xs)', lineHeight: 1.6, color: 'var(--text-muted)' }}>
|
||||
<strong style={{ color: 'var(--text)', display: 'block', marginBottom: 4 }}>Comment fonctionne le PFU sur les plateformes françaises ?</strong>
|
||||
Les plateformes françaises soumises à la Flat Tax retiennent à la source les prélèvements sociaux (17,2 %) et l'impôt forfaitaire (12,8 %).
|
||||
Le montant 2CK correspond à l'acompte IR déjà versé — il s'impute sur l'impôt définitif calculé lors de votre 2042.
|
||||
Le montant 2BH est déclaré en sus de 2TT/2TR pour neutraliser les prélèvements sociaux déjà prélevés.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Composant principal ── */
|
||||
export default function Cerfa2561Preview({ annee, activeView, onClose, inline = false, expanded = false, onToggleExpand }) {
|
||||
const { activeId } = useInvestisseur();
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filterPlat, setFilterPlat] = useState('all');
|
||||
const [view, setView] = useState('matrice');
|
||||
const contentRef = useRef(null);
|
||||
|
||||
const handlePrint = () => {
|
||||
const content = contentRef.current;
|
||||
if (!content) return;
|
||||
const platName = data?.lignes?.find(l => `${l.plateforme_id}_${l.investisseur_id}` === filterPlat)?.plateforme_nom ?? 'Plateforme';
|
||||
const printWin = window.open('', '_blank', 'width=900,height=700');
|
||||
printWin.document.write(`<!DOCTYPE html><html><head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>CERFA 2561 — ${annee} — ${platName}</title>
|
||||
<style>
|
||||
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
|
||||
body { margin: 0; padding: 20px; font-family: Arial, sans-serif; background: white; color: #111; }
|
||||
@media print { body { padding: 0; } }
|
||||
.no-print { display: none !important; }
|
||||
</style>
|
||||
</head><body>${content.innerHTML}</body></html>`);
|
||||
printWin.document.close();
|
||||
printWin.focus();
|
||||
setTimeout(() => { printWin.print(); printWin.close(); }, 400);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||
api.get('/taxreport/cerfa2561', { annee, ...scopeParams })
|
||||
.then(d => {
|
||||
setData(d);
|
||||
const firstFr = d.lignes.find(l => l.domiciliation === 'FR') ?? d.lignes[0];
|
||||
if (firstFr) setFilterPlat(`${firstFr.plateforme_id}_${firstFr.investisseur_id}`);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [annee, activeView]); // eslint-disable-line
|
||||
|
||||
const frLignes = (data?.lignes ?? []).filter(l => l.domiciliation === 'FR');
|
||||
|
||||
/* ── Tableau mensuel ── */
|
||||
const matriceView = (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
{loading && <div className="card text-muted">Chargement…</div>}
|
||||
{!loading && frLignes.length === 0 && (
|
||||
<div className="card"><p className="text-muted" style={{ margin: 0 }}>Aucune plateforme française avec des remboursements pour {annee}.</p></div>
|
||||
)}
|
||||
{!loading && frLignes.length > 0 && (
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, padding: '14px 14px 10px' }}>
|
||||
<h4 style={{ margin: 0, fontWeight: 700 }}>Suivi mensuel des intérêts bruts</h4>
|
||||
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Revenus {annee} — plateformes françaises</span>
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--fs-xs)' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--surface-2)', borderBottom: '1px solid var(--border)' }}>
|
||||
<th style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 600, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Plateforme — Détenteur</th>
|
||||
{MOIS_LABELS.map(m => (
|
||||
<th key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 600, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>{m}</th>
|
||||
))}
|
||||
<th style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 600, color: 'var(--text-muted)' }}>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{frLignes.map((l, idx) => {
|
||||
const total = r2(Object.values(l.mois ?? {}).reduce((s, m) => s + (m.interets_bruts ?? 0), 0));
|
||||
return (
|
||||
<tr key={`${l.plateforme_id}_${l.investisseur_id}`} style={{ borderBottom: '1px solid var(--border)', background: 'transparent' }}>
|
||||
<td style={{ padding: '6px 12px', fontWeight: 500, whiteSpace: 'nowrap' }}>
|
||||
{l.plateforme_nom}
|
||||
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginLeft: 6 }}>— {(() => { const n = l.investisseur_nom ?? ''; const p = l.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); })()}</span>
|
||||
</td>
|
||||
{MOIS_NUMS.map(m => {
|
||||
const v = l.mois?.[m]?.interets_bruts ?? 0;
|
||||
return (
|
||||
<td key={m} style={{ padding: '6px 8px', textAlign: 'right', color: v > 0 ? 'var(--text)' : 'var(--text-muted)' }}>
|
||||
{v > 0 ? v.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €' : '—'}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700 }}>
|
||||
{total > 0 ? total.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €' : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
{/* Total intérêt brut */}
|
||||
<tr style={{ borderTop: '2px solid var(--border)', background: 'var(--surface-2)' }}>
|
||||
<td style={{ padding: '6px 12px', fontWeight: 700 }}>Total intérêt brut</td>
|
||||
{MOIS_NUMS.map(m => {
|
||||
const v = r2(frLignes.reduce((s, l) => s + (l.mois?.[m]?.interets_bruts ?? 0), 0));
|
||||
return (
|
||||
<td key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 700, color: v > 0 ? 'var(--text)' : 'var(--text-muted)' }}>
|
||||
{v > 0 ? v.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '—'}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700 }}>
|
||||
{r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + (m.interets_bruts ?? 0), 0)), 0)).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €
|
||||
</td>
|
||||
</tr>
|
||||
{/* Taux prélevé */}
|
||||
<tr style={{ background: 'transparent' }}>
|
||||
<td style={{ padding: '4px 12px', color: 'var(--text-muted)', fontSize: 'var(--fs-xs)' }}>
|
||||
Taux total prélevé ({(() => {
|
||||
const totalBrut = r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + (m.interets_bruts ?? 0), 0)), 0));
|
||||
const totalPrelev = r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + ((m.prelev_sociaux ?? 0) + (m.prelev_forfaitaire ?? 0)), 0)), 0));
|
||||
return totalBrut > 0 ? (totalPrelev / totalBrut * 100).toFixed(1) : '—';
|
||||
})()} %)
|
||||
</td>
|
||||
{MOIS_NUMS.map(m => {
|
||||
const brut = r2(frLignes.reduce((s, l) => s + (l.mois?.[m]?.interets_bruts ?? 0), 0));
|
||||
const prelev = r2(frLignes.reduce((s, l) => s + ((l.mois?.[m]?.prelev_sociaux ?? 0) + (l.mois?.[m]?.prelev_forfaitaire ?? 0)), 0));
|
||||
const taux = brut > 0 ? (prelev / brut * 100).toFixed(1) + ' %' : '—';
|
||||
return (
|
||||
<td key={m} style={{ padding: '4px 8px', textAlign: 'right', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
|
||||
{taux}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td style={{ padding: '4px 12px', textAlign: 'right', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
|
||||
{(() => {
|
||||
const totalBrut = r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + (m.interets_bruts ?? 0), 0)), 0));
|
||||
const totalPrelev = r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + ((m.prelev_sociaux ?? 0) + (m.prelev_forfaitaire ?? 0)), 0)), 0));
|
||||
return totalBrut > 0 ? (totalPrelev / totalBrut * 100).toFixed(1) + ' %' : '—';
|
||||
})()}
|
||||
</td>
|
||||
</tr>
|
||||
{/* Total intérêt net */}
|
||||
<tr style={{ background: 'rgba(22,163,74,0.06)', borderTop: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '6px 12px', fontWeight: 700, color: 'var(--success)' }}>Total intérêt net</td>
|
||||
{MOIS_NUMS.map(m => {
|
||||
const net = r2(frLignes.reduce((s, l) => {
|
||||
const mo = l.mois?.[m];
|
||||
return s + ((mo?.interets_bruts ?? 0) - (mo?.prelev_sociaux ?? 0) - (mo?.prelev_forfaitaire ?? 0));
|
||||
}, 0));
|
||||
return (
|
||||
<td key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 700, color: net > 0 ? 'var(--success)' : 'var(--text-muted)' }}>
|
||||
{net > 0 ? net.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '—'}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700, color: 'var(--success)' }}>
|
||||
{r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + ((m.interets_bruts ?? 0) - (m.prelev_sociaux ?? 0) - (m.prelev_forfaitaire ?? 0)), 0)), 0)).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €
|
||||
</td>
|
||||
</tr>
|
||||
{/* Note de bas de tableau */}
|
||||
<tr style={{ background: 'var(--surface-2)' }}>
|
||||
<td colSpan={14} style={{ padding: '8px 12px', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', borderTop: '1px solid var(--border)' }}>
|
||||
Taux {annee} : PFO 12,8 % + CSG 10,6 % + CRDS 0,5 % + Solidarité 7,5 % = 31,4 % · Prélèvements effectués à la source par les plateformes · Report automatique sur la déclaration de revenus 2042
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ── Barre outils (vue cerfa) ── */
|
||||
const toolbar = (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
{data && frLignes.length > 0 && (
|
||||
<select
|
||||
value={filterPlat}
|
||||
onChange={e => setFilterPlat(e.target.value)}
|
||||
style={{ fontSize: 'var(--fs-sm)', padding: '4px 8px', width: 240 }}
|
||||
>
|
||||
{frLignes.map(l => (
|
||||
<option key={`${l.plateforme_id}_${l.investisseur_id}`} value={`${l.plateforme_id}_${l.investisseur_id}`}>
|
||||
{l.plateforme_nom} — {(() => { const n = l.investisseur_nom ?? ''; const p = l.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); })()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
{inline && onToggleExpand && (
|
||||
<button className="icon-btn" onClick={() => onToggleExpand()} title={expanded ? 'Réduire' : 'Agrandir'}>
|
||||
{expanded ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/>
|
||||
<line x1="10" y1="14" x2="3" y2="21"/><line x1="21" y1="3" x2="14" y2="10"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
|
||||
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button className="icon-btn" onClick={handlePrint} disabled={loading || !frLignes.length} title="Imprimer / Enregistrer en PDF">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6 9 6 2 18 2 18 9"/>
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/>
|
||||
<rect x="6" y="14" width="12" height="8"/>
|
||||
</svg>
|
||||
</button>
|
||||
{!inline && (
|
||||
<button className="icon-btn" onClick={onClose} title="Fermer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ── Vue cerfa ── */
|
||||
const cerfaView = (
|
||||
<div ref={contentRef}>
|
||||
{toolbar}
|
||||
{loading && <div style={{ textAlign: 'center', color: '#666', paddingTop: 20 }}>Chargement…</div>}
|
||||
{!loading && frLignes.length === 0 && (
|
||||
<div style={{ textAlign: 'center', color: '#666', paddingTop: 20 }}>Aucune plateforme française avec des remboursements pour {annee}.</div>
|
||||
)}
|
||||
{!loading && data?.lignes
|
||||
?.filter(l => `${l.plateforme_id}_${l.investisseur_id}` === filterPlat)
|
||||
.map((ligne, i, arr) => (
|
||||
<Cerfa2561Form key={`${ligne.plateforme_id}_${ligne.investisseur_id}`} ligne={ligne} index={i} total={arr.length} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', gap: 0, alignItems: 'flex-start' }}>
|
||||
{/* Sidebar nav */}
|
||||
<nav style={{ width: 200, flexShrink: 0, marginRight: 20, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '8px 0', boxSizing: 'border-box' }}>
|
||||
<div style={{ padding: '6px 12px 8px', fontSize: 'var(--fs-xs)', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.06em' }}>
|
||||
Sections
|
||||
</div>
|
||||
<button className={`account-nav-item${view === 'matrice' ? ' active' : ''}`} onClick={() => setView('matrice')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 3h18v2H3V3zm0 7h12v2H3v-2zm0 7h18v2H3v-2z"/>
|
||||
</svg>
|
||||
Suivi mensuel
|
||||
</button>
|
||||
<button className={`account-nav-item${view === 'cerfa' ? ' active' : ''}`} onClick={() => setView('cerfa')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
Données 2561
|
||||
</button>
|
||||
<button className={`account-nav-item${view === 'report2042' ? ' active' : ''}`} onClick={() => setView('report2042')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
Report 2042
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Contenu */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{view === 'matrice' && matriceView}
|
||||
{view === 'cerfa' && cerfaView}
|
||||
{view === 'report2042' && <Report2042Block2561 lignes={data?.lignes} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Vue modale (non-inline) ── */
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.6)', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="no-print" style={{ background: 'var(--surface)', borderBottom: '1px solid var(--border)', padding: '8px 16px', flexShrink: 0 }}>
|
||||
{toolbar}
|
||||
</div>
|
||||
<div ref={contentRef} style={{ flex: 1, overflowY: 'auto', padding: '24px 20px', background: '#e5e7eb' }}>
|
||||
{loading && <div style={{ textAlign: 'center', color: '#666', paddingTop: 40 }}>Chargement…</div>}
|
||||
{!loading && data?.lignes?.length === 0 && (
|
||||
<div style={{ textAlign: 'center', color: '#666', paddingTop: 40 }}>Aucun remboursement trouvé pour {annee}.</div>
|
||||
)}
|
||||
{!loading && data?.lignes
|
||||
?.filter(l => `${l.plateforme_id}_${l.investisseur_id}` === filterPlat)
|
||||
.map((ligne, i, arr) => (
|
||||
<Cerfa2561Form key={`${ligne.plateforme_id}_${ligne.investisseur_id}`} ligne={ligne} index={i} total={arr.length} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const MOIS_LABELS = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||||
const MOIS_NUMS = ['01','02','03','04','05','06','07','08','09','10','11','12'];
|
||||
|
||||
// Taux par défaut (2026+) — remplacés par les données de /api/pfu
|
||||
const DEFAULT_RATES = { pfo: 0.128, csg: 0.106, crds: 0.005, solidarite: 0.075 };
|
||||
|
||||
function getRatesForYear(annee, pfuList) {
|
||||
const yr = Number(annee);
|
||||
// Chercher l'année exacte, puis l'année précédente la plus proche
|
||||
const sorted = [...pfuList].sort((a, b) => b.annee - a.annee);
|
||||
const match = sorted.find(r => r.annee <= yr);
|
||||
if (!match) return DEFAULT_RATES;
|
||||
return {
|
||||
pfo: (match.impot_revenu ?? 12.8) / 100,
|
||||
csg: (match.csg ?? 9.2) / 100,
|
||||
crds: (match.crds ?? 0.5) / 100,
|
||||
solidarite: (match.solidarite ?? 7.5) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
const r = v => Math.round((v ?? 0) * 100) / 100;
|
||||
const fmtEUR = v => {
|
||||
if (v == null || v === 0) return <span style={{ color: 'var(--text-muted)' }}>- €</span>;
|
||||
return `${Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`;
|
||||
};
|
||||
const fmtInt = v => {
|
||||
if (v == null || v === 0) return <span style={{ color: 'var(--text-muted)' }}>-</span>;
|
||||
return `${v} €`;
|
||||
};
|
||||
|
||||
/* ── Simulation cases CERFA pour un montant brut ── */
|
||||
function computeCases(ba, rates) {
|
||||
const R = rates ?? DEFAULT_RATES;
|
||||
const BA = Math.round(ba);
|
||||
const IA = Math.round(BA * R.pfo);
|
||||
const PQ = Math.round(BA * R.csg);
|
||||
const PV = Math.round(BA * R.crds);
|
||||
const PF1 = PQ + PV;
|
||||
const PG1 = Math.round(BA * R.solidarite);
|
||||
const PU = PF1;
|
||||
const PK = PG1;
|
||||
const QR = IA + PU + PK;
|
||||
const totalTax = R.pfo + R.csg + R.crds + R.solidarite;
|
||||
return { BA, IA, PQ, PV, PF1, PG1, PU, PK, QR, totalTax, pfo: R.pfo, csgRate: R.csg, crdsRate: R.crds, solidRate: R.solidarite };
|
||||
}
|
||||
|
||||
/* ── Composant principal ── */
|
||||
export default function Cerfa2778Preview({ annee, activeView }) {
|
||||
const LS_KEY = 'cl_2778_excluded_plats';
|
||||
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pfuList, setPfuList] = useState([]);
|
||||
const [excluded, setExcluded] = useState(() => {
|
||||
try { return new Set(JSON.parse(localStorage.getItem(LS_KEY)) ?? []); }
|
||||
catch { return new Set(); }
|
||||
});
|
||||
const [selectedMois, setSelectedMois] = useState(null);
|
||||
const [view, setView] = useState('matrice'); // 'matrice' | 'cerfa'
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setData(null);
|
||||
setSelectedMois(null);
|
||||
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||
Promise.all([
|
||||
api.get('/taxreport/2778', { annee, ...scopeParams }),
|
||||
pfuList.length === 0 ? api.get('/pfu') : Promise.resolve(null),
|
||||
]).then(([d, pfu]) => {
|
||||
setData(d);
|
||||
if (pfu) setPfuList(pfu);
|
||||
}).then(([d]) => {
|
||||
// Auto-sélection du dernier mois avec données si vue cerfa active
|
||||
if (d?.plateformes) {
|
||||
const totaux = ['01','02','03','04','05','06','07','08','09','10','11','12'].map(m => {
|
||||
const stored = JSON.parse(localStorage.getItem('cl_2778_excluded_plats') ?? '[]');
|
||||
const excl = new Set(stored);
|
||||
return d.plateformes.filter(p => !excl.has(p.id)).reduce((s, p) => s + (p.mois[m] ?? 0), 0);
|
||||
});
|
||||
const last = totaux.reduce((idx, val, i) => val > 0 ? i : idx, null);
|
||||
if (last !== null) setSelectedMois(last);
|
||||
}
|
||||
}).finally(() => setLoading(false));
|
||||
}, [annee, activeView]); // eslint-disable-line
|
||||
|
||||
if (loading || !data) return <div className="card text-muted">Chargement…</div>;
|
||||
|
||||
const plateformes = data.plateformes;
|
||||
|
||||
const rates = getRatesForYear(annee, pfuList);
|
||||
const TOTAL_TAX = rates.pfo + rates.csg + rates.crds + rates.solidarite;
|
||||
|
||||
if (plateformes.length === 0) {
|
||||
return (
|
||||
<div className="card">
|
||||
<p className="text-muted" style={{ margin: 0 }}>
|
||||
Aucun remboursement de plateforme étrangère pour {annee}.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Auto-sélection du dernier mois avec données ── */
|
||||
// (calculé après le rendu initial)
|
||||
|
||||
/* ── Totaux mensuels des plateformes incluses ── */
|
||||
const totauxMois = MOIS_NUMS.map(m => {
|
||||
let sum = 0;
|
||||
for (const plat of plateformes) {
|
||||
if (!excluded.has(plat.id)) sum += plat.mois[m] ?? 0;
|
||||
}
|
||||
return r(sum);
|
||||
});
|
||||
|
||||
const lastMoisWithData = totauxMois.reduce((last, val, i) => val > 0 ? i : last, null);
|
||||
|
||||
/* ── Données du mois sélectionné pour simulation ── */
|
||||
const moisData = selectedMois !== null ? (() => {
|
||||
const mNum = MOIS_NUMS[selectedMois];
|
||||
const ba = totauxMois[selectedMois];
|
||||
const detail = plateformes
|
||||
.filter(p => !excluded.has(p.id) && (p.mois[mNum] ?? 0) > 0)
|
||||
.map(p => ({ nom: p.nom, montant: p.mois[mNum] }));
|
||||
return { ba, detail, cases: computeCases(ba, rates) };
|
||||
})() : null;
|
||||
|
||||
/* ── Toggle plateforme + persistance localStorage ── */
|
||||
const togglePlat = (id) => {
|
||||
setExcluded(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
localStorage.setItem(LS_KEY, JSON.stringify([...next]));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const cellStyle = {
|
||||
textAlign: 'right',
|
||||
padding: '6px 10px',
|
||||
fontSize: 'var(--fs-sm)',
|
||||
whiteSpace: 'nowrap',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
};
|
||||
const headStyle = {
|
||||
textAlign: 'center',
|
||||
padding: '6px 10px',
|
||||
fontSize: 'var(--fs-xs)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-muted)',
|
||||
background: 'var(--surface-2)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
{/* ── Navigation sidebar ── */}
|
||||
<div style={{ display: 'flex', gap: 0, alignItems: 'flex-start' }}>
|
||||
<nav style={{
|
||||
width: 200, flexShrink: 0, marginRight: 20,
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 10, padding: '8px 0', boxSizing: 'border-box',
|
||||
}}>
|
||||
<div style={{ padding: '6px 12px 8px', fontSize: 'var(--fs-xs)', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.06em' }}>
|
||||
Sections
|
||||
</div>
|
||||
<button
|
||||
className={`account-nav-item${view === 'matrice' ? ' active' : ''}`}
|
||||
onClick={() => { setView('matrice'); }}
|
||||
>
|
||||
<svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='1.8' strokeLinecap='round' strokeLinejoin='round'>
|
||||
<path d='M3 3h18v2H3V3zm0 7h12v2H3v-2zm0 7h18v2H3v-2z'/>
|
||||
</svg>
|
||||
Suivi mensuel
|
||||
</button>
|
||||
<button
|
||||
className={`account-nav-item${view === 'cerfa' ? ' active' : ''}`}
|
||||
onClick={() => { setView('cerfa'); if (selectedMois === null) setSelectedMois(lastMoisWithData); }}
|
||||
>
|
||||
<svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='1.8' strokeLinecap='round' strokeLinejoin='round'>
|
||||
<path d='M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'/>
|
||||
</svg>
|
||||
Données 2778-SD
|
||||
</button>
|
||||
<button
|
||||
className={`account-nav-item${view === 'report2042' ? ' active' : ''}`}
|
||||
onClick={() => { setView('report2042'); }}
|
||||
>
|
||||
<svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='1.8' strokeLinecap='round' strokeLinejoin='round'>
|
||||
<path d='M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z'/>
|
||||
</svg>
|
||||
Report 2042
|
||||
</button>
|
||||
<div style={{ margin: '10px 12px 6px', borderTop: '1px solid var(--border)' }} />
|
||||
<div style={{ padding: '4px 12px', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', lineHeight: 1.5 }}>
|
||||
Plateformes non-françaises<br/>Base = intérêts bruts avant retenue locale
|
||||
</div>
|
||||
</nav>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{/* ── Vue Matrice ── */}
|
||||
{view === 'matrice' && (
|
||||
<>
|
||||
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, padding: '14px 14px 10px' }}>
|
||||
<h4 style={{ margin: 0, fontWeight: 700 }}>Suivi mensuel des intérêts bruts</h4>
|
||||
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Revenus {annee} — plateformes étrangères</span>
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 900 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...headStyle, textAlign: 'left', minWidth: 180, position: 'sticky', left: 0, background: 'var(--surface-2)' }}>
|
||||
Plateforme — Détenteur
|
||||
</th>
|
||||
{MOIS_LABELS.map((m, i) => (
|
||||
<th key={m} style={headStyle}>{m}</th>
|
||||
))}
|
||||
<th style={{ ...headStyle, background: 'var(--surface-2)' }}>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plateformes.map(plat => {
|
||||
const isExcluded = excluded.has(plat.id);
|
||||
const total = Object.values(plat.mois).reduce((a, b) => a + b, 0);
|
||||
return (
|
||||
<tr key={plat.id} style={{ opacity: isExcluded ? 0.4 : 1, transition: 'opacity .15s' }}>
|
||||
<td style={{
|
||||
...cellStyle, textAlign: 'left',
|
||||
position: 'sticky', left: 0,
|
||||
background: 'var(--surface)',
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none', fontSize: 'var(--fs-sm)', textTransform: 'none', letterSpacing: 'normal', color: 'var(--text)', fontWeight: 400, marginBottom: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!isExcluded}
|
||||
onChange={() => togglePlat(plat.id)}
|
||||
style={{ accentColor: 'var(--primary)', width: 14, height: 14, flexShrink: 0 }}
|
||||
/>
|
||||
<span style={{ fontWeight: 500 }}>
|
||||
{plat.nom}
|
||||
{(plat.investisseur_nom || plat.investisseur_prenom) && (
|
||||
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginLeft: 6, fontWeight: 400 }}>
|
||||
— {(() => { const n = plat.investisseur_nom ?? ''; const p = plat.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); })()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</td>
|
||||
{MOIS_NUMS.map(m => (
|
||||
<td key={m} style={cellStyle}>{fmtEUR(plat.mois[m] ?? null)}</td>
|
||||
))}
|
||||
<td style={{ ...cellStyle, fontWeight: 600 }}>{fmtEUR(r(total))}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* ── Séparateur ── */}
|
||||
<tr><td colSpan={14} style={{ height: 4, background: 'var(--surface-2)' }} /></tr>
|
||||
|
||||
{/* ── Total brut mensuel ── */}
|
||||
<tr style={{ background: 'var(--surface-2)' }}>
|
||||
<td style={{ ...cellStyle, textAlign: 'left', fontWeight: 600, position: 'sticky', left: 0, background: 'var(--surface-2)' }}>
|
||||
Total intérêt brut
|
||||
</td>
|
||||
{totauxMois.map((t, i) => (
|
||||
<td key={i} style={{ ...cellStyle, fontWeight: 600 }}>{fmtEUR(t || null)}</td>
|
||||
))}
|
||||
<td style={{ ...cellStyle, fontWeight: 700 }}>
|
||||
{fmtEUR(r(totauxMois.reduce((a, b) => a + b, 0)))}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* ── Taux Flat Tax ── */}
|
||||
<tr>
|
||||
<td style={{ ...cellStyle, textAlign: 'left', color: 'var(--text-muted)', position: 'sticky', left: 0, background: 'var(--surface)' }}>
|
||||
Taux total prélevé ({(TOTAL_TAX * 100).toFixed(1)} %)
|
||||
</td>
|
||||
{MOIS_NUMS.map((_, i) => (
|
||||
<td key={i} style={{ ...cellStyle, color: 'var(--text-muted)' }}>
|
||||
{totauxMois[i] > 0 ? `${(TOTAL_TAX * 100).toFixed(2).replace('.', ',')} %` : '—'}
|
||||
</td>
|
||||
))}
|
||||
<td style={{ ...cellStyle, color: 'var(--text-muted)' }}>{`${(TOTAL_TAX * 100).toFixed(2).replace('.', ',')} %`}</td>
|
||||
</tr>
|
||||
|
||||
{/* ── Total Flat Tax ── */}
|
||||
<tr style={{ background: 'rgba(239,68,68,0.06)' }}>
|
||||
<td style={{ ...cellStyle, textAlign: 'left', fontWeight: 600, color: 'var(--danger)', position: 'sticky', left: 0, background: 'rgba(239,68,68,0.06)' }}>
|
||||
Montant total à payer (QR)
|
||||
</td>
|
||||
{totauxMois.map((t, i) => {
|
||||
const { QR } = computeCases(t, rates);
|
||||
return (
|
||||
<td key={i} style={{ ...cellStyle, fontWeight: 600, color: t > 0 ? 'var(--danger)' : undefined }}>
|
||||
{t > 0 ? fmtInt(QR) : <span style={{ color: 'var(--text-muted)' }}>-</span>}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td style={{ ...cellStyle, fontWeight: 700, color: 'var(--danger)' }}>
|
||||
{fmtInt(computeCases(r(totauxMois.reduce((a, b) => a + b, 0)), rates).QR)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* ── Intérêt net ── */}
|
||||
<tr style={{ background: 'rgba(16,185,129,0.05)' }}>
|
||||
<td style={{ ...cellStyle, textAlign: 'left', fontWeight: 600, color: 'var(--success)', position: 'sticky', left: 0, background: 'rgba(16,185,129,0.05)' }}>
|
||||
Total intérêt net
|
||||
</td>
|
||||
{totauxMois.map((t, i) => (
|
||||
<td key={i} style={{ ...cellStyle, fontWeight: 600, color: t > 0 ? 'var(--success)' : undefined }}>
|
||||
{fmtEUR(t > 0 ? r(t * (1 - TOTAL_TAX)) : null)}
|
||||
</td>
|
||||
))}
|
||||
<td style={{ ...cellStyle, fontWeight: 700, color: 'var(--success)' }}>
|
||||
{fmtEUR(r(totauxMois.reduce((a, b) => a + b, 0) * (1 - TOTAL_TAX)))}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style={{ padding: '10px 16px', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', borderTop: '1px solid var(--border)', display: 'flex', gap: 24, flexWrap: 'wrap' }}>
|
||||
<span>Taux {annee} : PFO {(rates.pfo*100).toFixed(1)} % + CSG {(rates.csg*100).toFixed(1)} % + CRDS {(rates.crds*100).toFixed(1)} % + Solidarité {(rates.solidarite*100).toFixed(1)} % = {(TOTAL_TAX*100).toFixed(1)} %</span>
|
||||
<span>·</span>
|
||||
<span>⏱ Déclaration et paiement dus dans les <strong>15 premiers jours du mois suivant</strong> l'encaissement</span>
|
||||
<span>·</span>
|
||||
<span>Base imposable : intérêts bruts <em>après</em> déduction de l'impôt prélevé à la source à l'étranger, <em>avant</em> déduction retenue "directive épargne"</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Vue Report 2042 ── */}
|
||||
{view === 'report2042' && (
|
||||
<Report2042Block annee={annee} totauxMois={totauxMois} rates={rates} plateformes={plateformes} excluded={excluded} />
|
||||
)}
|
||||
|
||||
{/* ── Vue Données 2778-SD ── */}
|
||||
{view === 'cerfa' && (
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||||
{/* Sélecteur de mois */}
|
||||
<div className="card" style={{ minWidth: 200, flexShrink: 0 }}>
|
||||
<h4 style={{ margin: '0 0 12px', fontSize: 'var(--fs-sm)', fontWeight: 600 }}>Mois</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{MOIS_LABELS.map((label, i) => {
|
||||
const ba = totauxMois[i];
|
||||
const isActive = selectedMois === i;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSelectedMois(i)}
|
||||
style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '7px 12px', borderRadius: 6, border: 'none',
|
||||
background: isActive ? 'var(--primary)' : (ba > 0 ? 'var(--surface-2)' : 'transparent'),
|
||||
color: isActive ? '#fff' : (ba > 0 ? 'var(--text)' : 'var(--text-muted)'),
|
||||
cursor: ba > 0 ? 'pointer' : 'default',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
fontSize: 'var(--fs-sm)',
|
||||
opacity: ba > 0 ? 1 : 0.5,
|
||||
}}
|
||||
disabled={ba === 0}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{ba > 0 && <span style={{ fontSize: 'var(--fs-xs)' }}>{Math.round(ba)} €</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* CERFA 2778-SD simulé */}
|
||||
{moisData && (
|
||||
<div style={{ flex: 1, minWidth: 400 }}>
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }}>
|
||||
<h4 style={{ margin: 0, fontWeight: 700 }}>
|
||||
Formulaire 2778-SD — {MOIS_LABELS[selectedMois]} {annee}
|
||||
</h4>
|
||||
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Simulation indicative</span>
|
||||
</div>
|
||||
|
||||
{/* Détail des plateformes incluses */}
|
||||
{moisData.detail.length > 1 && (
|
||||
<div style={{ marginBottom: 14, padding: '10px 12px', background: 'var(--surface-2)', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginBottom: 6, fontWeight: 500, textTransform: 'uppercase', letterSpacing: '.05em' }}>
|
||||
Plateformes incluses
|
||||
</div>
|
||||
{moisData.detail.map(d => (
|
||||
<div key={d.nom} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
|
||||
<span>{d.nom}</span>
|
||||
<span style={{ fontWeight: 500 }}>{r(d.montant).toLocaleString('fr-FR', { minimumFractionDigits: 2 })} €</span>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--fs-sm)', fontWeight: 700, borderTop: '1px solid var(--border)', marginTop: 6, paddingTop: 6 }}>
|
||||
<span>Total</span>
|
||||
<span>{r(moisData.ba).toLocaleString('fr-FR', { minimumFractionDigits: 2 })} €</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CerfaBlock cases={moisData.cases} ba_exact={moisData.ba} mois={selectedMois} annee={annee} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedMois === null && (
|
||||
<div className="card" style={{ flex: 1, color: 'var(--text-muted)', textAlign: 'center', padding: 40 }}>
|
||||
Sélectionnez un mois pour simuler le formulaire.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Report annuel 2042 ── */
|
||||
function Report2042Block({ annee, totauxMois, rates, plateformes, excluded }) {
|
||||
const totalBA = r(totauxMois.reduce((a, b) => a + b, 0));
|
||||
if (totalBA === 0) return null;
|
||||
|
||||
const totalIA = computeCases(totalBA, rates).IA;
|
||||
|
||||
// Détail par plateforme incluse (annuel)
|
||||
const platDetail = plateformes
|
||||
.filter(p => !excluded.has(p.id))
|
||||
.map(p => {
|
||||
const ba = r(Object.values(p.mois).reduce((a, b) => a + b, 0));
|
||||
return { nom: p.nom, ba, ia: computeCases(ba, rates).IA };
|
||||
})
|
||||
.filter(p => p.ba > 0);
|
||||
|
||||
const Case2042 = ({ code, label, note, value, breakdown }) => (
|
||||
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: '90px 1fr auto',
|
||||
gap: '0 12px', alignItems: 'center', padding: '10px 14px',
|
||||
}}>
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontWeight: 800, fontSize: 'var(--fs-base)',
|
||||
color: 'var(--primary)', background: 'rgba(99,102,241,0.07)',
|
||||
borderRadius: 6, padding: '2px 6px', textAlign: 'center', letterSpacing: '.04em',
|
||||
}}>{code}</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 'var(--fs-sm)', fontWeight: 600 }}>{label}</div>
|
||||
{note && <div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 2 }}>{note}</div>}
|
||||
</div>
|
||||
<div style={{ fontWeight: 800, fontSize: 'var(--fs-lg)', whiteSpace: 'nowrap', color: 'var(--text)' }}>
|
||||
{Number(value).toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} €
|
||||
</div>
|
||||
</div>
|
||||
{breakdown && breakdown.length > 1 && (
|
||||
<div style={{ padding: '0 14px 10px 118px', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{breakdown.map(p => (
|
||||
<div key={p.nom} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
|
||||
<span>└ {p.nom}</span>
|
||||
<span style={{ fontWeight: 500 }}>
|
||||
{Number(p.val).toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} €
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 14 }}>
|
||||
<h4 style={{ margin: 0, fontWeight: 700 }}>Report annuel — Déclaration 2042</h4>
|
||||
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Revenus {annee} à reporter sur la 2042 déposée en {Number(annee) + 1}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden', marginBottom: 14 }}>
|
||||
<Case2042
|
||||
code="2TR"
|
||||
label="Produits de placement à revenu fixe de source étrangère"
|
||||
note="Intérêts bruts totaux encaissés sur l'année (somme des cases BA)"
|
||||
value={Math.round(totalBA)}
|
||||
breakdown={platDetail.map(p => ({ nom: p.nom, val: Math.round(p.ba) }))}
|
||||
/>
|
||||
<Case2042
|
||||
code="2BH"
|
||||
label="Produits soumis aux prélèvements sociaux (idem 2TR)"
|
||||
note="Même montant que 2TR — évite la double imposition aux prélèvements sociaux"
|
||||
value={Math.round(totalBA)}
|
||||
breakdown={platDetail.map(p => ({ nom: p.nom, val: Math.round(p.ba) }))}
|
||||
/>
|
||||
<Case2042
|
||||
code="2CK"
|
||||
label="Prélèvement forfaitaire non libératoire déjà versé (acompte)"
|
||||
note="Somme des cases IA de vos 2778-SD — s'impute sur l'IR définitif, l'excédent est restitué"
|
||||
value={totalIA}
|
||||
breakdown={platDetail.map(p => ({ nom: p.nom, val: p.ia }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '10px 14px', borderRadius: 8,
|
||||
background: 'rgba(99,102,241,0.05)', border: '1px solid rgba(99,102,241,0.15)',
|
||||
fontSize: 'var(--fs-xs)', lineHeight: 1.6, color: 'var(--text-muted)',
|
||||
}}>
|
||||
<strong style={{ color: 'var(--text)', display: 'block', marginBottom: 4 }}>Comment fonctionne le PFO ?</strong>
|
||||
Le prélèvement forfaitaire obligatoire (PFO, case 2CK) versé via la 2778-SD est un <em>acompte</em> sur l'impôt sur le revenu, non libératoire.
|
||||
L'imposition définitive est calculée lors de votre 2042 : par défaut au taux de <strong>{(rates.pfo * 100).toFixed(1)} %</strong> (Flat Tax),
|
||||
ou sur option expresse au barème progressif. L'acompte déjà versé (2CK) s'impute sur l'IR définitif — tout excédent vous est restitué.
|
||||
Le montant 2BH est déclaré en sus de 2TR uniquement pour neutraliser les prélèvements sociaux déjà prélevés via la 2778-SD.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Bloc cases CERFA ── */
|
||||
function CerfaBlock({ cases, ba_exact, mois, annee }) {
|
||||
const { BA, IA, PQ, PV, PF1, PG1, PU, PK, QR } = cases;
|
||||
|
||||
const Row = ({ label, code, value, highlight, note, caseSup, base, taux, noUnit }) => (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 90px 90px 90px 90px 90px',
|
||||
gap: '0 8px',
|
||||
alignItems: 'baseline',
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: highlight ? 'rgba(239,68,68,0.05)' : 'transparent',
|
||||
}}>
|
||||
<div style={{ fontSize: 'var(--fs-sm)', whiteSpace: 'pre-line' }}>
|
||||
{label}
|
||||
{note && <span style={{ display: 'block', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 1 }}>{note}</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 'var(--fs-sm)', textAlign: 'center', color: 'var(--text-muted)' }}>{base != null ? `${base} €` : ''}</div>
|
||||
<div style={{ fontSize: 'var(--fs-xs)', textAlign: 'center', color: 'var(--text-muted)' }}>{taux ?? ''}</div>
|
||||
<div style={{ fontFamily: 'monospace', fontWeight: 700, fontSize: 'var(--fs-sm)', color: 'var(--primary)', textAlign: 'center' }}>{code}</div>
|
||||
<div style={{
|
||||
fontWeight: highlight ? 700 : 600,
|
||||
textAlign: 'center',
|
||||
color: highlight ? 'var(--danger)' : 'var(--text)',
|
||||
fontSize: highlight ? 'var(--fs-base)' : 'var(--fs-sm)',
|
||||
}}>{value != null ? (noUnit ? value : `${value} €`) : ''}</div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', alignSelf: 'center', textAlign: 'center' }}>{caseSup ?? ''}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SectionHeader = ({ title }) => (
|
||||
<div style={{
|
||||
padding: '6px 12px',
|
||||
background: 'var(--surface-2)',
|
||||
fontSize: 'var(--fs-xs)', fontWeight: 600,
|
||||
color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.06em',
|
||||
}}>{title}</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
{/* En-tête */}
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: '1fr 90px 90px 90px 90px 90px', gap: '0 8px',
|
||||
padding: '6px 12px', background: 'var(--surface-2)',
|
||||
fontSize: 'var(--fs-xs)', fontWeight: 600, color: 'var(--text-muted)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<span>Libellé</span><span style={{ textAlign: 'center' }}>Base imposable (BA)</span><span style={{ textAlign: 'center' }}>Taux</span><span style={{ textAlign: 'center' }}>Case</span><span style={{ textAlign: 'center' }}>Montant</span><span></span>
|
||||
</div>
|
||||
|
||||
<SectionHeader title="Page de garde (page 1)" />
|
||||
<Row label="Mois concerné par la déclaration" note="Mois au cours duquel les revenus ont été encaissés" code="" value={mois != null ? `${MOIS_LABELS[mois]} ${annee ?? new Date().getFullYear()}` : '—'} noUnit />
|
||||
<Row label="Paiement" note="Somme à payer — reporter le montant déterminé en dernière page, case QR" code="QR" value={QR} highlight />
|
||||
<SectionHeader title="Prélèvement forfaitaire obligatoire non libératoire (page 2)" />
|
||||
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)' }}>
|
||||
<span style={{ fontSize: 'var(--fs-sm)', fontWeight: 600 }}>Produits de placement à revenu fixe de source étrangère soumis au prélèvement forfaitaire obligatoire non libératoire</span>
|
||||
</div>
|
||||
<Row
|
||||
label={`Intérêts et produits des obligations, créances, dépôts, cautionnements, comptes courants, fonds communs de créances, bons de caisse.\nPrélèvement forfaitaire non libératoire (BA × ${(cases.pfo*100||12.8).toFixed(1)} %)` }
|
||||
code="IA"
|
||||
value={IA}
|
||||
base={BA}
|
||||
taux={`${(cases.pfo*100||12.8).toFixed(1)} %`}
|
||||
note={ba_exact !== BA ? `Montant exact : ${r(ba_exact).toLocaleString('fr-FR', { minimumFractionDigits: 2 })} € → arrondi à ${BA} €` : null}
|
||||
/>
|
||||
<Row label="Total prélèvement forfaitaire non libératoire (IA + …)" code="" value={IA} caseSup="A422" />
|
||||
|
||||
<SectionHeader title="Prélèvements sociaux (page 3)" />
|
||||
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)' }}>
|
||||
<span style={{ fontSize: 'var(--fs-sm)', fontWeight: 600 }}>Produits de placements à revenu fixe et produits afférents aux versements déductibles faisant l'objet d'un retrait en capital des PER de source étrangère</span>
|
||||
</div>
|
||||
<Row label={`CSG (BA × ${(cases.csgRate*100||10.6).toFixed(1)} %)`} code="PQ" value={PQ} base={BA} taux={`${(cases.csgRate*100||10.6).toFixed(1)} %`} />
|
||||
<Row label={`CRDS (BA × ${(cases.crdsRate*100||0.5).toFixed(1)} %)`} code="PV" value={PV} base={BA} taux={`${(cases.crdsRate*100||0.5).toFixed(1)} %`} />
|
||||
<Row label="Total prélèvements sociaux hors solidarité (PQ + PV) → PF1" code="PF1" value={PF1} />
|
||||
<Row label={`Prélèvement de solidarité (BA × ${(cases.solidRate*100||7.5).toFixed(1)} %)`} code="PG1" value={PG1} base={BA} taux={`${(cases.solidRate*100||7.5).toFixed(1)} %`} />
|
||||
|
||||
<SectionHeader title="Totaux à reporter (page 4)" />
|
||||
<Row label="Total prélèvements sociaux hors solidarité (PF1 + …)" code="PU" value={PU} caseSup="0701" />
|
||||
<Row label="Total prélèvement de solidarité (PG1 + …)" code="PK" value={PK} caseSup="A392" />
|
||||
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: '1fr 90px 90px 90px 90px 90px', gap: '0 8px',
|
||||
padding: '12px 12px',
|
||||
background: 'linear-gradient(135deg, rgba(239,68,68,0.08), rgba(239,68,68,0.04))',
|
||||
borderTop: '2px solid var(--danger)',
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, fontSize: 'var(--fs-sm)' }}>
|
||||
MONTANT TOTAL À PAYER (IA + PU + PK)
|
||||
<div style={{ fontSize: 'var(--fs-xs)', fontWeight: 400, color: 'var(--text-muted)', marginTop: 2 }}>
|
||||
À reporter en première page du formulaire
|
||||
</div>
|
||||
</div>
|
||||
<div></div><div></div>
|
||||
<div style={{ fontFamily: 'monospace', fontWeight: 700, color: 'var(--danger)', fontSize: 'var(--fs-base)', alignSelf: 'center', textAlign: 'center' }}>QR</div>
|
||||
<div style={{ fontWeight: 800, fontSize: '1.4rem', textAlign: 'center', color: 'var(--danger)', alignSelf: 'center' }}>{QR} €</div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { fmtEUR } from '../utils/format.js';
|
||||
|
||||
const ICONS_BASE = '/api/icons-files/';
|
||||
|
||||
/* Colonnes du tableau — dans l'ordre de la 2561 */
|
||||
const COLS = [
|
||||
{ key: 'case_2TT', code: '2TT', label: 'Produits prêts participatifs', color: '#7c3aed' },
|
||||
{ key: 'case_2TR', code: '2TR', label: 'Produits placement revenu fixe', color: '#7c3aed' },
|
||||
{ key: 'case_2TY', code: '2TY / AS',label: 'Pertes en capital', color: '#dc2626', danger: true },
|
||||
{ key: 'case_2BH', code: '2BH', label: 'Base CSG/CRDS (PS prélevés)', color: '#1d4ed8' },
|
||||
{ key: 'case_2CK', code: '2CK', label: "Crédit d'impôt prélèvement", color: '#059669' },
|
||||
];
|
||||
|
||||
export default function CerfaRecapTable({ annee, activeView }) {
|
||||
const [lignes, setLignes] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [libIcons, setLibIcons] = useState({});
|
||||
const [showFr, setShowFr] = useState(true);
|
||||
const [showWw, setShowWw] = useState(true);
|
||||
|
||||
/* Icônes bibliothèque */
|
||||
useEffect(() => {
|
||||
api.get('/icons').then(rows => {
|
||||
const m = {};
|
||||
rows.forEach(r => { m[r.name] = r.filename; });
|
||||
setLibIcons(m);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setLignes(null);
|
||||
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||
api.get('/taxreport/cerfa2561', { annee, ...scopeParams })
|
||||
.then(d => setLignes(d.lignes))
|
||||
.finally(() => setLoading(false));
|
||||
}, [annee, activeView]); // eslint-disable-line
|
||||
|
||||
/* Filtrage FR / WW */
|
||||
const filtered = useMemo(() => {
|
||||
if (!lignes) return [];
|
||||
return lignes.filter(l => {
|
||||
const isFr = l.domiciliation === 'FR';
|
||||
return isFr ? showFr : showWw;
|
||||
});
|
||||
}, [lignes, showFr, showWw]);
|
||||
|
||||
const totals = useMemo(() => (
|
||||
Object.fromEntries(
|
||||
COLS.map(c => [c.key, filtered.reduce((s, l) => s + (l[c.key] || 0), 0)])
|
||||
)
|
||||
), [filtered]);
|
||||
|
||||
/* Composant icône bibliothèque */
|
||||
const AppIcon = ({ name, size = 66, active }) => {
|
||||
const filename = libIcons[name];
|
||||
if (filename) return (
|
||||
<img
|
||||
src={ICONS_BASE + filename}
|
||||
className="app-lib-icon app-lib-icon-no-invert"
|
||||
width={size} height={size}
|
||||
aria-hidden="true"
|
||||
style={{ opacity: active ? 1 : 0.25, display: 'block', transition: 'opacity .15s' }}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<span style={{
|
||||
width: size, height: size, display: 'block', borderRadius: 8,
|
||||
background: 'var(--text-muted)', opacity: active ? 0.55 : 0.15,
|
||||
transition: 'opacity .15s',
|
||||
}} />
|
||||
);
|
||||
};
|
||||
|
||||
/* Détection présence de chaque type dans les données */
|
||||
const hasFr = lignes?.some(l => l.domiciliation === 'FR') ?? false;
|
||||
const hasWw = lignes?.some(l => l.domiciliation !== 'FR') ?? false;
|
||||
|
||||
/* Détecter si plusieurs détenteurs distincts */
|
||||
const multiDetenteur =
|
||||
new Set((lignes ?? []).map(l => l.investisseur_id).filter(v => v != null)).size > 1;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="solde-chart-wrap" style={{ padding: '24px', marginBottom: 0 }}>
|
||||
<span className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>Chargement…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!lignes || lignes.length === 0) {
|
||||
return (
|
||||
<div className="solde-chart-wrap" style={{ padding: '24px', marginBottom: 0 }}>
|
||||
<span className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>Aucune donnée pour {annee}.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="solde-chart-wrap" style={{ padding: '20px 24px 16px', marginBottom: 0 }}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="solde-chart-header" style={{ marginBottom: 12 }}>
|
||||
<div className="solde-chart-info">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', marginBottom: 2 }}>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
background: 'rgba(124,58,237,0.12)', borderRadius: 5, padding: '3px 8px',
|
||||
}}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: 2, background: '#7c3aed', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 13, color: '#7c3aed', fontWeight: 600 }}>Cases 2561</span>
|
||||
</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>· {annee}</span>
|
||||
</div>
|
||||
<div className="solde-chart-value" style={{ fontSize: '1.4rem' }}>
|
||||
{fmtEUR(totals.case_2TT + totals.case_2TR)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Boutons filtre FR / WW */}
|
||||
<div className="solde-chart-controls">
|
||||
{hasFr && (
|
||||
<button
|
||||
title={showFr ? 'Plateformes françaises incluses' : 'Afficher les plateformes françaises'}
|
||||
onClick={() => setShowFr(v => !v)}
|
||||
style={{
|
||||
background: showFr ? 'rgba(59,130,246,0.12)' : 'none',
|
||||
border: '1px solid ' + (showFr ? 'rgba(59,130,246,0.5)' : 'transparent'),
|
||||
borderRadius: 10, padding: '5px 7px', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center',
|
||||
transition: 'background .15s, border-color .15s',
|
||||
}}
|
||||
>
|
||||
<div style={{ background: '#fff', borderRadius: 7, lineHeight: 0 }}>
|
||||
<AppIcon name="plateforme-fr" size={44} active={showFr} />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{hasWw && (
|
||||
<button
|
||||
title={showWw ? 'Plateformes étrangères incluses' : 'Afficher les plateformes étrangères'}
|
||||
onClick={() => setShowWw(v => !v)}
|
||||
style={{
|
||||
background: showWw ? 'rgba(16,185,129,0.12)' : 'none',
|
||||
border: '1px solid ' + (showWw ? 'rgba(16,185,129,0.5)' : 'transparent'),
|
||||
borderRadius: 10, padding: '5px 7px', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center',
|
||||
transition: 'background .15s, border-color .15s',
|
||||
}}
|
||||
>
|
||||
<div style={{ background: '#fff', borderRadius: 7, lineHeight: 0 }}>
|
||||
<AppIcon name="plateforme-ww" size={44} active={showWw} />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>
|
||||
Aucune plateforme à afficher — activez au moins un filtre.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="tip-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="tip-th-name tip-th-name-amber">Plateforme</th>
|
||||
{COLS.map(c => (
|
||||
<th key={c.key} className="tip-th-month" style={{ minWidth: 110 }}>
|
||||
<span style={{ display: 'block', fontWeight: 800, letterSpacing: '.03em' }}>{c.code}</span>
|
||||
<span style={{ display: 'block', fontSize: 10, fontWeight: 400, opacity: .85, marginTop: 1 }}>{c.label}</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(l => (
|
||||
<tr key={`${l.plateforme_id}_${l.investisseur_id}`} className="tip-row-plat">
|
||||
<td className="tip-td-name">
|
||||
{l.plateforme_nom}
|
||||
{multiDetenteur && l.investisseur_prenom && (
|
||||
<span style={{ marginLeft: 6, fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>
|
||||
{l.investisseur_prenom}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{COLS.map(c => {
|
||||
const v = l[c.key] || 0;
|
||||
return (
|
||||
<td
|
||||
key={c.key}
|
||||
className="tip-td-num"
|
||||
style={c.danger && v > 0 ? { color: '#dc2626', fontWeight: 600 } : undefined}
|
||||
>
|
||||
{v > 0 ? fmtEUR(v) : <span className="tip-dash">---</span>}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="tip-footer-total">
|
||||
<td className="tip-td-name">Total {annee}</td>
|
||||
{COLS.map(c => {
|
||||
const v = totals[c.key] || 0;
|
||||
return (
|
||||
<td
|
||||
key={c.key}
|
||||
className="tip-td-total"
|
||||
style={c.danger && v > 0 ? { color: '#dc2626' } : undefined}
|
||||
>
|
||||
{v > 0 ? fmtEUR(v) : <span className="tip-dash">---</span>}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p style={{ margin: '10px 0 0', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
⚠ Montants indicatifs. Référez-vous à votre IFU et à la notice 2041-GFI avant toute déclaration.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Modal from './Modal.jsx';
|
||||
|
||||
/**
|
||||
* Modale de confirmation générique.
|
||||
* Props :
|
||||
* open — booléen
|
||||
* title — titre de la modale (défaut : "Confirmer la suppression")
|
||||
* message — texte explicatif
|
||||
* confirmLabel — libellé du bouton de confirmation (défaut : "Supprimer")
|
||||
* onConfirm — callback appelé au clic "Confirmer"
|
||||
* onCancel — callback appelé au clic "Annuler" ou ✕
|
||||
*/
|
||||
export default function ConfirmModal({
|
||||
open,
|
||||
title = 'Confirmer la suppression',
|
||||
message,
|
||||
confirmLabel = 'Supprimer',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
onClose={onCancel}
|
||||
width={440}
|
||||
footer={
|
||||
<>
|
||||
<button className="ghost" type="button" onClick={onCancel}>Annuler</button>
|
||||
<button className="danger" type="button" onClick={onConfirm}>{confirmLabel}</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p style={{ margin: '8px 0 4px', color: 'var(--text)', lineHeight: 1.5 }}>{message}</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
// Composant drapeau via flag-icons CSS (cdnjs)
|
||||
// Usage : <FlagIcon code="FR" />
|
||||
const FlagIcon = ({ code, size = 20 }) => (
|
||||
<span
|
||||
className={`fi fi-${(code || 'fr').toLowerCase()}`}
|
||||
style={{ width: size, height: Math.round(size * 0.75), display: 'inline-block', flexShrink: 0, borderRadius: 2 }}
|
||||
/>
|
||||
);
|
||||
|
||||
// Liste complète ISO 3166-1 alpha-2 — noms en français
|
||||
const COUNTRIES = [
|
||||
{ code: 'AF', name: 'Afghanistan' },
|
||||
{ code: 'ZA', name: 'Afrique du Sud' },
|
||||
{ code: 'AL', name: 'Albanie' },
|
||||
{ code: 'DZ', name: 'Algérie' },
|
||||
{ code: 'DE', name: 'Allemagne' },
|
||||
{ code: 'AD', name: 'Andorre' },
|
||||
{ code: 'AO', name: 'Angola' },
|
||||
{ code: 'AG', name: 'Antigua-et-Barbuda' },
|
||||
{ code: 'SA', name: 'Arabie saoudite' },
|
||||
{ code: 'AR', name: 'Argentine' },
|
||||
{ code: 'AM', name: 'Arménie' },
|
||||
{ code: 'AU', name: 'Australie' },
|
||||
{ code: 'AT', name: 'Autriche' },
|
||||
{ code: 'AZ', name: 'Azerbaïdjan' },
|
||||
{ code: 'BS', name: 'Bahamas' },
|
||||
{ code: 'BH', name: 'Bahreïn' },
|
||||
{ code: 'BD', name: 'Bangladesh' },
|
||||
{ code: 'BB', name: 'Barbade' },
|
||||
{ code: 'BY', name: 'Biélorussie' },
|
||||
{ code: 'BE', name: 'Belgique' },
|
||||
{ code: 'BZ', name: 'Belize' },
|
||||
{ code: 'BJ', name: 'Bénin' },
|
||||
{ code: 'BT', name: 'Bhoutan' },
|
||||
{ code: 'BO', name: 'Bolivie' },
|
||||
{ code: 'BA', name: 'Bosnie-Herzégovine' },
|
||||
{ code: 'BW', name: 'Botswana' },
|
||||
{ code: 'BR', name: 'Brésil' },
|
||||
{ code: 'BN', name: 'Brunéi' },
|
||||
{ code: 'BG', name: 'Bulgarie' },
|
||||
{ code: 'BF', name: 'Burkina Faso' },
|
||||
{ code: 'BI', name: 'Burundi' },
|
||||
{ code: 'CV', name: 'Cap-Vert' },
|
||||
{ code: 'KH', name: 'Cambodge' },
|
||||
{ code: 'CM', name: 'Cameroun' },
|
||||
{ code: 'CA', name: 'Canada' },
|
||||
{ code: 'CF', name: 'République centrafricaine' },
|
||||
{ code: 'CL', name: 'Chili' },
|
||||
{ code: 'CN', name: 'Chine' },
|
||||
{ code: 'CY', name: 'Chypre' },
|
||||
{ code: 'CO', name: 'Colombie' },
|
||||
{ code: 'KM', name: 'Comores' },
|
||||
{ code: 'CG', name: 'Congo' },
|
||||
{ code: 'CD', name: 'Congo (RDC)' },
|
||||
{ code: 'KP', name: 'Corée du Nord' },
|
||||
{ code: 'KR', name: 'Corée du Sud' },
|
||||
{ code: 'CR', name: 'Costa Rica' },
|
||||
{ code: 'HR', name: 'Croatie' },
|
||||
{ code: 'CU', name: 'Cuba' },
|
||||
{ code: 'DK', name: 'Danemark' },
|
||||
{ code: 'DJ', name: 'Djibouti' },
|
||||
{ code: 'DO', name: 'République dominicaine' },
|
||||
{ code: 'DM', name: 'Dominique' },
|
||||
{ code: 'EG', name: 'Égypte' },
|
||||
{ code: 'SV', name: 'Salvador' },
|
||||
{ code: 'AE', name: 'Émirats arabes unis' },
|
||||
{ code: 'EC', name: 'Équateur' },
|
||||
{ code: 'ER', name: 'Érythrée' },
|
||||
{ code: 'ES', name: 'Espagne' },
|
||||
{ code: 'EE', name: 'Estonie' },
|
||||
{ code: 'SZ', name: 'Eswatini' },
|
||||
{ code: 'ET', name: 'Éthiopie' },
|
||||
{ code: 'FJ', name: 'Fidji' },
|
||||
{ code: 'FI', name: 'Finlande' },
|
||||
{ code: 'FR', name: 'France' },
|
||||
{ code: 'GA', name: 'Gabon' },
|
||||
{ code: 'GM', name: 'Gambie' },
|
||||
{ code: 'GE', name: 'Géorgie' },
|
||||
{ code: 'GH', name: 'Ghana' },
|
||||
{ code: 'GR', name: 'Grèce' },
|
||||
{ code: 'GD', name: 'Grenade' },
|
||||
{ code: 'GT', name: 'Guatemala' },
|
||||
{ code: 'GN', name: 'Guinée' },
|
||||
{ code: 'GW', name: 'Guinée-Bissau' },
|
||||
{ code: 'GQ', name: 'Guinée équatoriale' },
|
||||
{ code: 'GY', name: 'Guyana' },
|
||||
{ code: 'HT', name: 'Haïti' },
|
||||
{ code: 'HN', name: 'Honduras' },
|
||||
{ code: 'HU', name: 'Hongrie' },
|
||||
{ code: 'IN', name: 'Inde' },
|
||||
{ code: 'ID', name: 'Indonésie' },
|
||||
{ code: 'IQ', name: 'Irak' },
|
||||
{ code: 'IR', name: 'Iran' },
|
||||
{ code: 'IE', name: 'Irlande' },
|
||||
{ code: 'IS', name: 'Islande' },
|
||||
{ code: 'IL', name: 'Israël' },
|
||||
{ code: 'IT', name: 'Italie' },
|
||||
{ code: 'JM', name: 'Jamaïque' },
|
||||
{ code: 'JP', name: 'Japon' },
|
||||
{ code: 'JO', name: 'Jordanie' },
|
||||
{ code: 'KZ', name: 'Kazakhstan' },
|
||||
{ code: 'KE', name: 'Kenya' },
|
||||
{ code: 'KG', name: 'Kirghizistan' },
|
||||
{ code: 'KI', name: 'Kiribati' },
|
||||
{ code: 'KW', name: 'Koweït' },
|
||||
{ code: 'LA', name: 'Laos' },
|
||||
{ code: 'LS', name: 'Lesotho' },
|
||||
{ code: 'LV', name: 'Lettonie' },
|
||||
{ code: 'LB', name: 'Liban' },
|
||||
{ code: 'LR', name: 'Liberia' },
|
||||
{ code: 'LY', name: 'Libye' },
|
||||
{ code: 'LI', name: 'Liechtenstein' },
|
||||
{ code: 'LT', name: 'Lituanie' },
|
||||
{ code: 'LU', name: 'Luxembourg' },
|
||||
{ code: 'MK', name: 'Macédoine du Nord' },
|
||||
{ code: 'MG', name: 'Madagascar' },
|
||||
{ code: 'MY', name: 'Malaisie' },
|
||||
{ code: 'MW', name: 'Malawi' },
|
||||
{ code: 'MV', name: 'Maldives' },
|
||||
{ code: 'ML', name: 'Mali' },
|
||||
{ code: 'MT', name: 'Malte' },
|
||||
{ code: 'MA', name: 'Maroc' },
|
||||
{ code: 'MH', name: 'Îles Marshall' },
|
||||
{ code: 'MU', name: 'Maurice' },
|
||||
{ code: 'MR', name: 'Mauritanie' },
|
||||
{ code: 'MX', name: 'Mexique' },
|
||||
{ code: 'FM', name: 'Micronésie' },
|
||||
{ code: 'MD', name: 'Moldavie' },
|
||||
{ code: 'MC', name: 'Monaco' },
|
||||
{ code: 'MN', name: 'Mongolie' },
|
||||
{ code: 'ME', name: 'Monténégro' },
|
||||
{ code: 'MZ', name: 'Mozambique' },
|
||||
{ code: 'MM', name: 'Myanmar' },
|
||||
{ code: 'NA', name: 'Namibie' },
|
||||
{ code: 'NR', name: 'Nauru' },
|
||||
{ code: 'NP', name: 'Népal' },
|
||||
{ code: 'NI', name: 'Nicaragua' },
|
||||
{ code: 'NE', name: 'Niger' },
|
||||
{ code: 'NG', name: 'Nigeria' },
|
||||
{ code: 'NO', name: 'Norvège' },
|
||||
{ code: 'NZ', name: 'Nouvelle-Zélande' },
|
||||
{ code: 'OM', name: 'Oman' },
|
||||
{ code: 'UG', name: 'Ouganda' },
|
||||
{ code: 'UZ', name: 'Ouzbékistan' },
|
||||
{ code: 'PK', name: 'Pakistan' },
|
||||
{ code: 'PW', name: 'Palaos' },
|
||||
{ code: 'PA', name: 'Panama' },
|
||||
{ code: 'PG', name: 'Papouasie-Nouvelle-Guinée' },
|
||||
{ code: 'PY', name: 'Paraguay' },
|
||||
{ code: 'NL', name: 'Pays-Bas' },
|
||||
{ code: 'PE', name: 'Pérou' },
|
||||
{ code: 'PH', name: 'Philippines' },
|
||||
{ code: 'PL', name: 'Pologne' },
|
||||
{ code: 'PT', name: 'Portugal' },
|
||||
{ code: 'QA', name: 'Qatar' },
|
||||
{ code: 'RO', name: 'Roumanie' },
|
||||
{ code: 'GB', name: 'Royaume-Uni' },
|
||||
{ code: 'RU', name: 'Russie' },
|
||||
{ code: 'RW', name: 'Rwanda' },
|
||||
{ code: 'KN', name: 'Saint-Kitts-et-Nevis' },
|
||||
{ code: 'SM', name: 'Saint-Marin' },
|
||||
{ code: 'VC', name: 'Saint-Vincent-et-les-Grenadines' },
|
||||
{ code: 'LC', name: 'Sainte-Lucie' },
|
||||
{ code: 'SB', name: 'Îles Salomon' },
|
||||
{ code: 'WS', name: 'Samoa' },
|
||||
{ code: 'ST', name: 'Sao Tomé-et-Principe' },
|
||||
{ code: 'SN', name: 'Sénégal' },
|
||||
{ code: 'RS', name: 'Serbie' },
|
||||
{ code: 'SC', name: 'Seychelles' },
|
||||
{ code: 'SL', name: 'Sierra Leone' },
|
||||
{ code: 'SG', name: 'Singapour' },
|
||||
{ code: 'SK', name: 'Slovaquie' },
|
||||
{ code: 'SI', name: 'Slovénie' },
|
||||
{ code: 'SO', name: 'Somalie' },
|
||||
{ code: 'SD', name: 'Soudan' },
|
||||
{ code: 'SS', name: 'Soudan du Sud' },
|
||||
{ code: 'LK', name: 'Sri Lanka' },
|
||||
{ code: 'SE', name: 'Suède' },
|
||||
{ code: 'CH', name: 'Suisse' },
|
||||
{ code: 'SR', name: 'Suriname' },
|
||||
{ code: 'SY', name: 'Syrie' },
|
||||
{ code: 'TJ', name: 'Tadjikistan' },
|
||||
{ code: 'TZ', name: 'Tanzanie' },
|
||||
{ code: 'TD', name: 'Tchad' },
|
||||
{ code: 'CZ', name: 'Tchéquie' },
|
||||
{ code: 'TH', name: 'Thaïlande' },
|
||||
{ code: 'TL', name: 'Timor oriental' },
|
||||
{ code: 'TG', name: 'Togo' },
|
||||
{ code: 'TO', name: 'Tonga' },
|
||||
{ code: 'TT', name: 'Trinité-et-Tobago' },
|
||||
{ code: 'TN', name: 'Tunisie' },
|
||||
{ code: 'TM', name: 'Turkménistan' },
|
||||
{ code: 'TR', name: 'Turquie' },
|
||||
{ code: 'TV', name: 'Tuvalu' },
|
||||
{ code: 'UA', name: 'Ukraine' },
|
||||
{ code: 'UY', name: 'Uruguay' },
|
||||
{ code: 'VU', name: 'Vanuatu' },
|
||||
{ code: 'VE', name: 'Venezuela' },
|
||||
{ code: 'VN', name: 'Viêt Nam' },
|
||||
{ code: 'YE', name: 'Yémen' },
|
||||
{ code: 'ZM', name: 'Zambie' },
|
||||
{ code: 'ZW', name: 'Zimbabwe' },
|
||||
{ code: 'US', name: 'États-Unis' },
|
||||
].sort((a, b) => a.name.localeCompare(b.name, 'fr'));
|
||||
|
||||
export { COUNTRIES, FlagIcon };
|
||||
|
||||
/**
|
||||
* CountrySelect — combobox pays avec drapeaux emoji et recherche par frappe
|
||||
*
|
||||
* Props :
|
||||
* value : code ISO 2 lettres (ex. 'FR')
|
||||
* onChange : (code) => void
|
||||
* required : bool (optionnel)
|
||||
*/
|
||||
export default function CountrySelect({ value, onChange, required, showCode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const containerRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const listRef = useRef(null);
|
||||
|
||||
const selected = COUNTRIES.find(c => c.code === value);
|
||||
|
||||
const filtered = search
|
||||
? COUNTRIES.filter(c =>
|
||||
c.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.code.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: COUNTRIES;
|
||||
|
||||
// Ferme le dropdown si clic en dehors
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
const select = (code) => {
|
||||
onChange(code);
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
const handleOpen = () => {
|
||||
setOpen(true);
|
||||
setSearch('');
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
// Navigation clavier dans la liste
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') { setOpen(false); setSearch(''); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ position: 'relative' }}>
|
||||
{/* Trigger */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={open ? () => { setOpen(false); setSearch(''); } : handleOpen}
|
||||
style={{
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
padding: '0 10px',
|
||||
height: 36,
|
||||
background: 'var(--surface-2, var(--surface))',
|
||||
border: open ? '1px solid var(--primary)' : '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontSize: 14,
|
||||
color: 'var(--text)',
|
||||
outline: open ? '2px solid var(--primary)' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
>
|
||||
{selected ? (
|
||||
<>
|
||||
<FlagIcon code={selected.code} />
|
||||
<span>{selected.name}</span>
|
||||
{showCode && <span style={{ color: 'var(--text-muted)', fontSize: 12 }}>({selected.code})</span>}
|
||||
</>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-muted)' }}>Sélectionner un pays…</span>
|
||||
)}
|
||||
<svg style={{ marginLeft: 'auto', flexShrink: 0, opacity: 0.5, transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{open && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 'calc(100% + 4px)',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 500,
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.15)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Champ de recherche */}
|
||||
<div style={{ padding: '8px 8px 4px' }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Rechercher un pays…"
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: '6px 10px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
background: 'var(--surface-2, var(--surface))',
|
||||
color: 'var(--text)',
|
||||
fontSize: 13,
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Liste des pays */}
|
||||
<div
|
||||
ref={listRef}
|
||||
style={{
|
||||
maxHeight: 128,
|
||||
overflowY: 'auto',
|
||||
padding: '4px 4px 8px',
|
||||
}}
|
||||
>
|
||||
{filtered.length === 0 && (
|
||||
<div style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 13 }}>
|
||||
Aucun résultat
|
||||
</div>
|
||||
)}
|
||||
{filtered.map(c => (
|
||||
<button
|
||||
key={c.code}
|
||||
type="button"
|
||||
onClick={() => select(c.code)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
width: '100%',
|
||||
padding: '6px 10px',
|
||||
border: 'none',
|
||||
borderRadius: 5,
|
||||
background: c.code === value ? 'var(--primary-light, rgba(99,102,241,0.1))' : 'transparent',
|
||||
color: c.code === value ? 'var(--primary)' : 'var(--text)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
textAlign: 'left',
|
||||
fontWeight: c.code === value ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
<FlagIcon code={c.code} />
|
||||
<span>{c.name}</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--text-muted)', opacity: 0.7 }}>{c.code}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { fmtEUR } from '../utils/format.js';
|
||||
|
||||
const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||||
const ICONS_BASE = '/api/icons-files/';
|
||||
const COLOR_DEPOT = '#22c55e';
|
||||
const COLOR_RETRAIT = '#ef4444';
|
||||
|
||||
function hexToRgba(hex, a) {
|
||||
if (!hex || hex.length < 7) return `rgba(0,0,0,${a})`;
|
||||
const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16);
|
||||
return `rgba(${r},${g},${b},${a})`;
|
||||
}
|
||||
|
||||
function ChevronDown({ size = 10 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DepotsMensuelTable({ allRows, plats, expandButton }) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const currentMonth = new Date().getMonth() + 1;
|
||||
|
||||
const [annee, setAnnee] = useState(currentYear);
|
||||
const [inclureDepots, setInclureDepots] = useState(true);
|
||||
const [inclureRetraits, setInclureRetraits] = useState(false);
|
||||
const [libIcons, setLibIcons] = useState({});
|
||||
|
||||
/* ── Toggle consolidation détenteurs ── */
|
||||
const [groupByNom, setGroupByNom] = useState(() => {
|
||||
try { return localStorage.getItem('cl_tip_group_by_nom') === 'true'; } catch { return false; }
|
||||
});
|
||||
const toggleGroupByNom = () => {
|
||||
setGroupByNom(v => {
|
||||
const next = !v;
|
||||
try { localStorage.setItem('cl_tip_group_by_nom', String(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
/* Icones bibliotheque */
|
||||
useEffect(() => {
|
||||
api.get('/icons').then(rows => {
|
||||
const m = {};
|
||||
rows.forEach(r => { m[r.name] = r.filename; });
|
||||
setLibIcons(m);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const AppIcon = ({ name, size = 28, active = false }) => {
|
||||
const filename = libIcons[name];
|
||||
if (filename) return (
|
||||
<img src={ICONS_BASE + filename} className="app-lib-icon" width={size} height={size}
|
||||
aria-hidden="true"
|
||||
style={{ opacity: active ? 1 : 0.35, display: 'block', transition: 'opacity .15s' }} />
|
||||
);
|
||||
return <span style={{ width: size, height: size, display: 'block', borderRadius: 4,
|
||||
background: 'var(--text-muted)', opacity: active ? 0.55 : 0.2, transition: 'opacity .15s' }} />;
|
||||
};
|
||||
|
||||
/* Annees disponibles */
|
||||
const availableYears = useMemo(() => {
|
||||
const set = new Set(allRows.map(r => r.date_operation?.slice(0, 4)).filter(Boolean));
|
||||
return [...set].map(Number).sort((a, b) => a - b);
|
||||
}, [allRows]);
|
||||
|
||||
/* Fenetre selecteur */
|
||||
const [windowStart, setWindowStart] = useState(() => {
|
||||
const idx = availableYears.indexOf(currentYear);
|
||||
const safe = idx >= 0 ? idx : availableYears.length - 1;
|
||||
return Math.max(0, Math.min(Math.max(0, availableYears.length - 3), safe - 1));
|
||||
});
|
||||
const visibleYears = availableYears.length ? availableYears.slice(windowStart, windowStart + 3) : [annee];
|
||||
const canPrev = windowStart > 0;
|
||||
const canNext = windowStart + 3 < availableYears.length;
|
||||
|
||||
/* Grille par plateforme x mois */
|
||||
const { grid, stats, multiDetenteur } = useMemo(() => {
|
||||
const anneeStr = String(annee);
|
||||
const rows = allRows.filter(r => r.date_operation?.slice(0, 4) === anneeStr);
|
||||
|
||||
const byPlat = {};
|
||||
for (const r of rows) {
|
||||
const pid = r.plateforme_id;
|
||||
if (!byPlat[pid]) {
|
||||
byPlat[pid] = {
|
||||
id: pid,
|
||||
nom: r.plateforme_nom || '—',
|
||||
investisseur_id: r.investisseur_id ?? null,
|
||||
detenteur_nom: r.plateforme_detenteur_nom || null,
|
||||
depots: Array(12).fill(0),
|
||||
retraits: Array(12).fill(0),
|
||||
};
|
||||
}
|
||||
const mi = parseInt(r.date_operation.slice(5, 7), 10) - 1;
|
||||
if (r.type === 'depot') byPlat[pid].depots[mi] += r.montant || 0;
|
||||
if (r.type === 'retrait') byPlat[pid].retraits[mi] += r.montant || 0;
|
||||
}
|
||||
|
||||
const allPlats = Object.values(byPlat);
|
||||
const multi = new Set(allPlats.map(p => p.investisseur_id).filter(v => v != null)).size > 1;
|
||||
|
||||
// Consolidation par nom si demandée
|
||||
let consolidated;
|
||||
if (groupByNom && multi) {
|
||||
const byNom = {};
|
||||
for (const p of allPlats) {
|
||||
if (!byNom[p.nom]) {
|
||||
byNom[p.nom] = { id: p.nom, nom: p.nom, investisseur_id: null, detenteur_nom: null,
|
||||
depots: [...p.depots], retraits: [...p.retraits] };
|
||||
} else {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
byNom[p.nom].depots[i] += p.depots[i];
|
||||
byNom[p.nom].retraits[i] += p.retraits[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
consolidated = Object.values(byNom);
|
||||
} else {
|
||||
consolidated = allPlats;
|
||||
}
|
||||
|
||||
const cellValue = (p, mi) => {
|
||||
const d = inclureDepots ? p.depots[mi] : 0;
|
||||
const rv = inclureRetraits ? p.retraits[mi] : 0;
|
||||
if (inclureDepots && inclureRetraits) return d - rv;
|
||||
return d + rv;
|
||||
};
|
||||
|
||||
const grid = consolidated
|
||||
.map(p => ({ ...p, months: Array.from({ length: 12 }, (_, i) => cellValue(p, i)) }))
|
||||
.filter(p => p.months.some(v => v !== 0))
|
||||
.sort((a, b) => b.depots.reduce((s, v) => s + v, 0) - a.depots.reduce((s, v) => s + v, 0));
|
||||
|
||||
const monthTotals = Array.from({ length: 12 }, (_, i) =>
|
||||
grid.reduce((s, row) => s + row.months[i], 0));
|
||||
const grandTotal = monthTotals.reduce((s, v) => s + v, 0);
|
||||
const platTotals = grid.map(row => row.months.reduce((s, v) => s + v, 0));
|
||||
const nonZero = monthTotals.filter(v => v !== 0);
|
||||
const globalMoyenne = nonZero.length ? nonZero.reduce((s, v) => s + v, 0) / nonZero.length : 0;
|
||||
|
||||
return { grid, stats: { monthTotals, grandTotal, platTotals, globalMoyenne }, multiDetenteur: multi };
|
||||
}, [allRows, annee, inclureDepots, inclureRetraits, groupByNom]);
|
||||
|
||||
return (
|
||||
<div className="solde-chart-wrap" style={{ padding: '24px 24px 16px', marginBottom: 24 }}>
|
||||
|
||||
<div className="solde-chart-header">
|
||||
<div className="solde-chart-info">
|
||||
<div style={{ display:'flex', alignItems:'center', gap:5, flexWrap:'wrap', marginBottom:2 }}>
|
||||
{inclureDepots && (
|
||||
<span style={{ display:'inline-flex', alignItems:'center', gap:4,
|
||||
background: hexToRgba(COLOR_DEPOT, 0.12), borderRadius:5, padding:'3px 8px' }}>
|
||||
<span style={{ width:7, height:7, borderRadius:2, background: COLOR_DEPOT, flexShrink:0 }}/>
|
||||
<span style={{ fontSize:13, color: COLOR_DEPOT, fontWeight:600 }}>Depots</span>
|
||||
</span>
|
||||
)}
|
||||
{inclureRetraits && (
|
||||
<span style={{ display:'inline-flex', alignItems:'center', gap:4,
|
||||
background: hexToRgba(COLOR_RETRAIT, 0.12), borderRadius:5, padding:'3px 8px' }}>
|
||||
<span style={{ width:7, height:7, borderRadius:2, background: COLOR_RETRAIT, flexShrink:0 }}/>
|
||||
<span style={{ fontSize:13, color: COLOR_RETRAIT, fontWeight:600 }}>Retraits</span>
|
||||
</span>
|
||||
)}
|
||||
{!inclureDepots && !inclureRetraits && (
|
||||
<span style={{ fontSize:13, color:'var(--text-muted)' }}>---</span>
|
||||
)}
|
||||
<span style={{ fontSize:13, color:'var(--text-muted)' }}>. {annee}</span>
|
||||
</div>
|
||||
<div className="solde-chart-value">{fmtEUR(stats.grandTotal)}</div>
|
||||
</div>
|
||||
|
||||
<div className="solde-chart-controls">
|
||||
<button
|
||||
title={inclureDepots ? 'Depots inclus' : 'Inclure les depots'}
|
||||
onClick={() => setInclureDepots(v => !v)}
|
||||
style={{ background: inclureDepots ? hexToRgba(COLOR_DEPOT, 0.13) : 'none',
|
||||
border: '1px solid ' + (inclureDepots ? COLOR_DEPOT : 'transparent'),
|
||||
borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center',
|
||||
transition:'background .15s,border-color .15s', marginRight:2 }}>
|
||||
<AppIcon name="depot" active={inclureDepots} />
|
||||
</button>
|
||||
<button
|
||||
title={inclureRetraits ? 'Retraits inclus' : 'Inclure les retraits'}
|
||||
onClick={() => setInclureRetraits(v => !v)}
|
||||
style={{ background: inclureRetraits ? hexToRgba(COLOR_RETRAIT, 0.13) : 'none',
|
||||
border: '1px solid ' + (inclureRetraits ? COLOR_RETRAIT : 'transparent'),
|
||||
borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center',
|
||||
transition:'background .15s,border-color .15s', marginRight:2 }}>
|
||||
<AppIcon name="retrait" active={inclureRetraits} />
|
||||
</button>
|
||||
|
||||
<div className="solde-chart-ranges">
|
||||
<button className="solde-range-btn"
|
||||
onClick={() => setWindowStart(w => Math.max(0, w - 1))}
|
||||
disabled={!canPrev} style={{ opacity: canPrev ? 1 : 0.3 }}>‹</button>
|
||||
{visibleYears.map(y => (
|
||||
<button key={y}
|
||||
className={'solde-range-btn' + (annee === y ? ' active' : '')}
|
||||
onClick={() => setAnnee(y)}>
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
<button className="solde-range-btn"
|
||||
onClick={() => setWindowStart(w => Math.min(Math.max(0, availableYears.length - 3), w + 1))}
|
||||
disabled={!canNext} style={{ opacity: canNext ? 1 : 0.3 }}>›</button>
|
||||
<button
|
||||
className={'solde-range-btn' + (annee === currentYear ? ' active' : '')}
|
||||
onClick={() => setAnnee(currentYear)}>
|
||||
TOUT
|
||||
</button>
|
||||
{expandButton}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{grid.length === 0 ? (
|
||||
<div style={{ marginTop: 20, color: 'var(--text-muted)', fontSize: 'var(--fs-sm)', padding: '24px 0', textAlign: 'center' }}>
|
||||
{!inclureDepots && !inclureRetraits
|
||||
? 'Selectionne au moins un type de mouvement.'
|
||||
: 'Aucun mouvement pour ' + annee + '.'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto', marginTop: 20, position: 'relative', zIndex: 0 }}>
|
||||
<table className="tip-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="tip-th-empty" />
|
||||
<th className="tip-th-year" colSpan={12}>{annee}</th>
|
||||
<th className="tip-th-empty" />
|
||||
<th className="tip-th-empty" />
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="tip-th-name tip-th-name-amber">
|
||||
<span style={{ display:'flex', alignItems:'center', gap:6 }}>
|
||||
Plateforme
|
||||
{multiDetenteur && (
|
||||
<button
|
||||
onClick={() => toggleGroupByNom()}
|
||||
title={groupByNom ? 'Vue consolidée — cliquer pour détailler par détenteur' : 'Vue détaillée — cliquer pour consolider par plateforme'}
|
||||
style={{
|
||||
display:'inline-flex', alignItems:'center', gap:3,
|
||||
background:'rgba(255,255,255,0.15)', border:'1px solid rgba(255,255,255,0.3)',
|
||||
borderRadius:4, padding:'2px 5px', cursor:'pointer',
|
||||
fontSize:10, fontWeight:600, color:'#fff', letterSpacing:'.03em',
|
||||
lineHeight:1.4, whiteSpace:'nowrap', transition:'background .15s',
|
||||
}}>
|
||||
{groupByNom ? 'Consolidé' : 'Détaillé'}
|
||||
<span style={{ display:'inline-flex', transition:'transform .2s', transform: groupByNom ? 'rotate(180deg)' : 'rotate(0deg)' }}>
|
||||
<ChevronDown size={9} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
{MOIS_LONG.map((m, i) => (
|
||||
<th key={m}
|
||||
className={'tip-th-month' + (annee === currentYear && i === currentMonth - 1 ? ' tip-th-month-current' : '')}>
|
||||
{m}
|
||||
</th>
|
||||
))}
|
||||
<th className="tip-th-total">Total</th>
|
||||
<th className="tip-th-avg">Moy. mensuelle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{grid.map((plat, pi) => (
|
||||
<tr key={plat.id} className="tip-row-plat">
|
||||
<td className="tip-td-name">
|
||||
{plat.nom}
|
||||
{!groupByNom && multiDetenteur && plat.detenteur_nom && (
|
||||
<span style={{ marginLeft: 6, fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>
|
||||
{plat.detenteur_nom}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{plat.months.map((v, mi) => (
|
||||
<td key={mi}
|
||||
className={'tip-td-num' + (annee === currentYear && mi === currentMonth - 1 ? ' tip-col-current' : '')}
|
||||
style={v < 0 ? { color: COLOR_RETRAIT } : undefined}>
|
||||
{v !== 0 ? fmtEUR(v) : <span className="tip-dash">---</span>}
|
||||
</td>
|
||||
))}
|
||||
<td className="tip-td-total" style={stats.platTotals[pi] < 0 ? { color: COLOR_RETRAIT } : undefined}>
|
||||
{stats.platTotals[pi] !== 0 ? fmtEUR(stats.platTotals[pi]) : <span className="tip-dash">---</span>}
|
||||
</td>
|
||||
<td className="tip-td-avg">
|
||||
{stats.platTotals[pi] !== 0 ? fmtEUR(stats.platTotals[pi] / 12) : <span className="tip-dash">---</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="tip-footer-total">
|
||||
<td className="tip-td-name">Toutes les plateformes</td>
|
||||
{stats.monthTotals.map((v, i) => (
|
||||
<td key={i}
|
||||
className={'tip-td-num' + (annee === currentYear && i === currentMonth - 1 ? ' tip-col-current' : '')}
|
||||
style={v < 0 ? { color: COLOR_RETRAIT } : undefined}>
|
||||
{v !== 0 ? fmtEUR(v) : <span className="tip-dash">---</span>}
|
||||
</td>
|
||||
))}
|
||||
<td className="tip-td-total" style={stats.grandTotal < 0 ? { color: COLOR_RETRAIT } : undefined}>
|
||||
{fmtEUR(stats.grandTotal)}
|
||||
</td>
|
||||
<td className="tip-td-avg">
|
||||
{stats.globalMoyenne !== 0 ? fmtEUR(stats.globalMoyenne) : <span className="tip-dash">---</span>}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useMemo, useState, useRef, useCallback } from 'react';
|
||||
|
||||
/* ── Palette ────────────────────────────────────────────────── */
|
||||
const PALETTE = [
|
||||
'#e8547a', '#9333ea', '#0d9488', '#f59e0b',
|
||||
'#3b82f6', '#10b981', '#f97316', '#06b6d4',
|
||||
'#a855f7', '#84cc16', '#ec4899', '#14b8a6',
|
||||
];
|
||||
|
||||
/* ── Algorithme treemap (binary split équilibré) ────────────── */
|
||||
function buildTreemap(items, x, y, w, h) {
|
||||
if (!items.length) return [];
|
||||
if (items.length === 1) return [{ ...items[0], x, y, w, h }];
|
||||
|
||||
const total = items.reduce((s, i) => s + i.value, 0);
|
||||
let best = 1, bestDiff = Infinity, acc = 0;
|
||||
for (let i = 0; i < items.length - 1; i++) {
|
||||
acc += items[i].value;
|
||||
const diff = Math.abs(acc - (total - acc));
|
||||
if (diff < bestDiff) { bestDiff = diff; best = i + 1; }
|
||||
}
|
||||
|
||||
const g1 = items.slice(0, best);
|
||||
const g2 = items.slice(best);
|
||||
const r1 = g1.reduce((s, i) => s + i.value, 0) / total;
|
||||
|
||||
if (w >= h) {
|
||||
const w1 = w * r1;
|
||||
return [
|
||||
...buildTreemap(g1, x, y, w1, h),
|
||||
...buildTreemap(g2, x + w1, y, w - w1, h),
|
||||
];
|
||||
} else {
|
||||
const h1 = h * r1;
|
||||
return [
|
||||
...buildTreemap(g1, x, y, w, h1),
|
||||
...buildTreemap(g2, x, y + h1, w, h - h1),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Formatage : nombre entier arrondi, jamais de k€ ── */
|
||||
function fmtAmount(v) {
|
||||
return Math.round(v).toLocaleString('fr-FR') + ' €';
|
||||
}
|
||||
|
||||
/* ── Word-wrap SVG : découpe un nom en lignes selon la largeur dispo ── */
|
||||
function wrapText(text, maxWidth, fontSize) {
|
||||
const charW = fontSize * 0.58; // largeur approx d'un caractère
|
||||
const maxChars = Math.max(1, Math.floor(maxWidth / charW));
|
||||
const words = text.split(' ');
|
||||
const lines = [];
|
||||
let current = '';
|
||||
for (const word of words) {
|
||||
const candidate = current ? current + ' ' + word : word;
|
||||
if (candidate.length <= maxChars) {
|
||||
current = candidate;
|
||||
} else {
|
||||
if (current) lines.push(current);
|
||||
current = word.length > maxChars ? word.slice(0, maxChars - 1) + '…' : word;
|
||||
}
|
||||
}
|
||||
if (current) lines.push(current);
|
||||
return lines.slice(0, 3); // max 3 lignes
|
||||
}
|
||||
|
||||
/* ── Composant ──────────────────────────────────────────────── */
|
||||
export default function DistributionChart({ rows }) {
|
||||
const [hoveredIdx, setHoveredIdx] = useState(null);
|
||||
const [tooltip, setTooltip] = useState(null); // { x, y, cell }
|
||||
const svgRef = useRef(null);
|
||||
const wrapRef = useRef(null);
|
||||
|
||||
/* Solde net par plateforme */
|
||||
const { data, allRetraits } = useMemo(() => {
|
||||
if (!rows?.length) return { data: [], allRetraits: false };
|
||||
const allRetraits = rows.every(r => r.type === 'retrait');
|
||||
const byPlat = {};
|
||||
for (const r of rows) {
|
||||
const key = r.plateforme_nom || `#${r.plateforme_id}`;
|
||||
byPlat[key] = (byPlat[key] || 0) + r.montant;
|
||||
}
|
||||
const data = Object.entries(byPlat)
|
||||
.filter(([, v]) => v > 0)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([name, value], i) => ({ name, value, color: PALETTE[i % PALETTE.length] }));
|
||||
return { data, allRetraits };
|
||||
}, [rows]);
|
||||
|
||||
const total = data.reduce((s, i) => s + i.value, 0);
|
||||
|
||||
const W = 440, H = 290, GAP = 3;
|
||||
|
||||
const cells = useMemo(() => buildTreemap(data, 0, 0, W, H), [data]);
|
||||
|
||||
/* Conversion coordonnées SVG → pixels dans le wrapper */
|
||||
const handleMouseMove = useCallback((e, cell, idx) => {
|
||||
if (!svgRef.current || !wrapRef.current) return;
|
||||
const svgRect = svgRef.current.getBoundingClientRect();
|
||||
const wrapRect = wrapRef.current.getBoundingClientRect();
|
||||
// Position relative au wrapper (pour le tooltip absolu)
|
||||
const tx = e.clientX - wrapRect.left;
|
||||
const ty = e.clientY - wrapRect.top;
|
||||
setHoveredIdx(idx);
|
||||
setTooltip({ x: tx, y: ty, cell });
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setHoveredIdx(null);
|
||||
setTooltip(null);
|
||||
}, []);
|
||||
|
||||
if (!cells.length) return null;
|
||||
|
||||
/* Position tooltip : évite les débordements */
|
||||
const TIP_W = 150, TIP_H = 66;
|
||||
const tipStyle = tooltip ? (() => {
|
||||
const ww = wrapRef.current?.offsetWidth || 400;
|
||||
const wh = wrapRef.current?.offsetHeight || 360;
|
||||
let tx = tooltip.x + 14;
|
||||
let ty = tooltip.y - TIP_H / 2;
|
||||
if (tx + TIP_W > ww - 4) tx = tooltip.x - TIP_W - 14;
|
||||
if (ty < 4) ty = 4;
|
||||
if (ty + TIP_H > wh - 4) ty = wh - TIP_H - 4;
|
||||
return { left: tx, top: ty };
|
||||
})() : null;
|
||||
|
||||
return (
|
||||
<div className="dist-chart-wrap" ref={wrapRef}>
|
||||
|
||||
{/* ── En-tête ── */}
|
||||
<div className="dist-chart-header">
|
||||
<span className="dist-chart-title">Distribution</span>
|
||||
<div className="dist-dropdown">
|
||||
par Plateforme
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Treemap SVG ── */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
style={{ width: '100%', height: 'auto', display: 'block' }}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="dt-shadow" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#000" stopOpacity="0" />
|
||||
<stop offset="100%" stopColor="#000" stopOpacity="0.4" />
|
||||
</linearGradient>
|
||||
<filter id="dt-txt-shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="1.5" floodColor="#000" floodOpacity="0.55"/>
|
||||
</filter>
|
||||
{/* clipPath par cellule — défini dynamiquement dans chaque <g> */}
|
||||
{cells.map((cell, i) => {
|
||||
const PAD = 4;
|
||||
const gx = cell.x + GAP / 2 + PAD;
|
||||
const gy = cell.y + GAP / 2 + PAD;
|
||||
const gw = Math.max(cell.w - GAP - PAD * 2, 0);
|
||||
const gh = Math.max(cell.h - GAP - PAD * 2, 0);
|
||||
return (
|
||||
<clipPath key={i} id={`clip-${i}`}>
|
||||
<rect x={gx} y={gy} width={gw} height={gh} />
|
||||
</clipPath>
|
||||
);
|
||||
})}
|
||||
</defs>
|
||||
|
||||
{cells.map((cell, i) => {
|
||||
const gx = cell.x + GAP / 2;
|
||||
const gy = cell.y + GAP / 2;
|
||||
const gw = Math.max(cell.w - GAP, 0);
|
||||
const gh = Math.max(cell.h - GAP, 0);
|
||||
const cx = gx + gw / 2;
|
||||
const cy = gy + gh / 2;
|
||||
const pct = ((cell.value / total) * 100).toFixed(0);
|
||||
const amt = (allRetraits ? '− ' : '') + fmtAmount(cell.value);
|
||||
|
||||
const isHovered = hoveredIdx === i;
|
||||
|
||||
/* Taille de police adaptée à la largeur disponible */
|
||||
const fsAmt = Math.min(15, Math.max(10, gw / 7.5));
|
||||
const fsName = Math.min(12, Math.max(8, gw / 9.5));
|
||||
const fsPct = Math.min(11, Math.max(7, gw / 11));
|
||||
|
||||
const lineH = 15;
|
||||
|
||||
/* Seuils d'affichage */
|
||||
const canShowAmt = gw > 36 && gh > 20;
|
||||
|
||||
/* Mode "paysage" : cellule plus large que haute */
|
||||
const isLandscape = gw > gh;
|
||||
|
||||
/* Texte combiné "Nom — X %" : utilisé en paysage seulement s'il tient sur 1 ligne */
|
||||
const combinedText = `${cell.name} — ${pct} %`;
|
||||
const combinedCharW = fsName * 0.58;
|
||||
const combinedFits = isLandscape && (combinedText.length * combinedCharW) <= (gw - 10);
|
||||
|
||||
/* Word-wrap du nom seul (mode portrait ou paysage sans place pour le combiné) */
|
||||
const nameLines = (gw > 40 && gh > 30)
|
||||
? wrapText(cell.name, gw - 10, fsName)
|
||||
: [];
|
||||
const canShowName = nameLines.length > 0;
|
||||
|
||||
/* Pourcentage séparé : s'assurer que la ligne % rentre dans le clipPath (PAD=4 de chaque côté) */
|
||||
const CLIP_PAD = 4;
|
||||
const textHeightSoFar = (canShowAmt ? lineH : 0) + (canShowName ? nameLines.length * lineH : 0);
|
||||
const canShowPct = !combinedFits && gw > 44 && (gh - CLIP_PAD * 2) >= textHeightSoFar + lineH;
|
||||
|
||||
/* Pré-calcul des positions Y */
|
||||
const textItems = [];
|
||||
{
|
||||
const slots = [];
|
||||
if (canShowAmt) slots.push({ type: 'amt', h: lineH + 2 });
|
||||
if (combinedFits) {
|
||||
slots.push({ type: 'combined', text: combinedText, h: lineH });
|
||||
} else {
|
||||
if (canShowName) nameLines.forEach((l, li) => slots.push({ type: 'name', li, text: l, h: lineH }));
|
||||
if (canShowPct) slots.push({ type: 'pct', h: lineH });
|
||||
}
|
||||
|
||||
const totalH = slots.reduce((s, sl) => s + sl.h, 0);
|
||||
let y = cy - totalH / 2 + lineH / 2;
|
||||
|
||||
for (const sl of slots) {
|
||||
if (sl.type === 'amt') textItems.push({ key: 'amt', text: amt, fs: fsAmt, fw: '700', op: 1, y });
|
||||
if (sl.type === 'combined') textItems.push({ key: 'combined', text: sl.text, fs: fsName, fw: '500', op: 0.92, y });
|
||||
if (sl.type === 'name') textItems.push({ key: `n${sl.li}`, text: sl.text, fs: fsName, fw: '500', op: 0.92, y });
|
||||
if (sl.type === 'pct') textItems.push({ key: 'pct', text: pct + ' %', fs: fsPct, fw: '400', op: 0.75, y });
|
||||
y += sl.h;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<g key={i} style={{ cursor: 'pointer' }}
|
||||
onMouseMove={e => handleMouseMove(e, { ...cell, pct, amt }, i)}
|
||||
>
|
||||
{/* Cellule colorée */}
|
||||
<rect x={gx} y={gy} width={gw} height={gh}
|
||||
rx="5" fill={cell.color}
|
||||
opacity={hoveredIdx !== null && !isHovered ? 0.45 : 0.9}
|
||||
style={{ transition: 'opacity .15s' }}
|
||||
/>
|
||||
{/* Dégradé sombre */}
|
||||
<rect x={gx} y={gy} width={gw} height={gh}
|
||||
rx="5" fill="url(#dt-shadow)" opacity="0.35" />
|
||||
{/* Bordure lumineuse au hover */}
|
||||
{isHovered && (
|
||||
<rect x={gx} y={gy} width={gw} height={gh}
|
||||
rx="5" fill="none"
|
||||
stroke="rgba(255,255,255,0.55)" strokeWidth="1.5" />
|
||||
)}
|
||||
{/* Labels */}
|
||||
<g clipPath={`url(#clip-${i})`}>
|
||||
{textItems.map(l => (
|
||||
<text key={l.key} x={cx} y={l.y}
|
||||
textAnchor="middle" dominantBaseline="middle"
|
||||
fill="white"
|
||||
fillOpacity={hoveredIdx !== null && !isHovered ? 0.3 : l.op}
|
||||
fontSize={l.fs} fontWeight={l.fw}
|
||||
fontFamily="system-ui,-apple-system,sans-serif"
|
||||
filter="url(#dt-txt-shadow)"
|
||||
style={{ transition: 'fill-opacity .15s' }}>
|
||||
{l.text}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* ── Tooltip ── */}
|
||||
{tooltip && tipStyle && (
|
||||
<div className="dist-tooltip" style={{ left: tipStyle.left, top: tipStyle.top }}>
|
||||
<div className="dist-tooltip-dot" style={{ background: tooltip.cell.color }} />
|
||||
<div>
|
||||
<div className="dist-tooltip-amt">{tooltip.cell.amt}</div>
|
||||
<div className="dist-tooltip-name">{tooltip.cell.name}</div>
|
||||
<div className="dist-tooltip-pct">{tooltip.cell.pct} %</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useInteretsChart } from '../context/InteretsChartContext.jsx';
|
||||
import { fmtEUR, fmtDate } from '../utils/format.js';
|
||||
import Modal from './Modal.jsx';
|
||||
|
||||
const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||||
|
||||
function hexToRgba(hex, a) {
|
||||
if (!hex || hex.length < 7) return `rgba(79,168,232,${a})`;
|
||||
const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16);
|
||||
return `rgba(${r},${g},${b},${a})`;
|
||||
}
|
||||
|
||||
const round2 = v => Math.round(v * 100) / 100;
|
||||
|
||||
/**
|
||||
* DrillCellPanel
|
||||
*
|
||||
* Props :
|
||||
* cell { platId, platNom, annee, mois, moisLabel } | null
|
||||
* platId peut être null → toutes les plateformes
|
||||
* onClose () => void (masqué si alwaysOpen=true)
|
||||
* alwaysOpen bool — cache le bouton Fermer, ajuste le style
|
||||
* pfuRates []
|
||||
* activeView 'single'|'all'
|
||||
* activeId number|null
|
||||
* plateformes [] — pour le sélecteur (optionnel)
|
||||
* investissements [] — pour le calcul bulk (optionnel)
|
||||
* onBulkDone () => void — callback après validation en masse
|
||||
*/
|
||||
export default function DrillCellPanel({
|
||||
cell, onClose, alwaysOpen = false,
|
||||
pfuRates, activeView, activeId,
|
||||
onEditRecu, onEditProjet, refreshKey,
|
||||
investissements, plateformes, onBulkDone,
|
||||
}) {
|
||||
const {
|
||||
inclureInterets, setInclureInterets,
|
||||
inclureCapital, setInclureCapital,
|
||||
inclureCashback, setInclureCashback,
|
||||
netMode,
|
||||
showActual, showProjected,
|
||||
chartInterets, chartCapital, chartCashback,
|
||||
} = useInteretsChart();
|
||||
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
/* filterPlatId : '' = toutes, sinon l'id (string) de la plateforme sélectionnée dans le dropdown */
|
||||
const [filterPlatId, setFilterPlatId] = useState('');
|
||||
const [showRecus, setShowRecus] = useState(showActual);
|
||||
const [showProjetes, setShowProjetes] = useState(showProjected);
|
||||
|
||||
/* ── Bulk validation ── */
|
||||
const [bulkModal, setBulkModal] = useState(false);
|
||||
const [bulkItems, setBulkItems] = useState([]);
|
||||
const [bulkProcessing, setBulkProcessing] = useState(false);
|
||||
const [bulkProgress, setBulkProgress] = useState(0);
|
||||
const [bulkDone, setBulkDone] = useState(false);
|
||||
|
||||
/* ── Reset filtres quand la cellule change ── */
|
||||
useEffect(() => {
|
||||
if (!cell) { setData(null); return; }
|
||||
setShowRecus(showActual);
|
||||
setShowProjetes(showProjected);
|
||||
/* Quand une cellule spécifique est cliquée, pré-sélectionner sa plateforme */
|
||||
setFilterPlatId(cell.platId ? String(cell.platId) : '');
|
||||
}, [cell?.platId, cell?.annee, cell?.mois]);
|
||||
|
||||
/* ── Fetch quand cellule OU filterPlatId change ── */
|
||||
useEffect(() => {
|
||||
if (!cell) { setData(null); return; }
|
||||
setLoading(true);
|
||||
setData(null);
|
||||
const params = {
|
||||
annee: cell.annee,
|
||||
mois: cell.mois,
|
||||
...(filterPlatId ? { plateforme_id: filterPlatId } : {}),
|
||||
...(activeView === 'all' ? { scope: 'all' } : {}),
|
||||
};
|
||||
api.get('/dashboard/detail-cellule', params)
|
||||
.then(d => setData(d))
|
||||
.catch(() => setData({ recus: [], projetes: [] }))
|
||||
.finally(() => setLoading(false));
|
||||
}, [cell?.annee, cell?.mois, filterPlatId, activeView, activeId, refreshKey]);
|
||||
|
||||
/* ── Taux PFU pour l'année de la cellule ── */
|
||||
const pfuReduction = useMemo(() => {
|
||||
if (!pfuRates?.length || !cell) return 0;
|
||||
const r = pfuRates.find(r => r.annee === cell.annee)
|
||||
?? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]);
|
||||
return (r.prelev_sociaux + r.impot_revenu) / 100;
|
||||
}, [pfuRates, cell]);
|
||||
|
||||
/* ── Données filtrées (la plateforme est gérée côté fetch) ── */
|
||||
const recus = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return data.recus;
|
||||
}, [data]);
|
||||
|
||||
const projetes = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return data.projetes;
|
||||
}, [data]);
|
||||
|
||||
if (!cell) return null;
|
||||
|
||||
/* ── Afficher la colonne Plateforme quand on est en mode "toutes" ── */
|
||||
const showPlatCol = !filterPlatId;
|
||||
const multiDetenteur = plateformes && new Set(plateformes.map(p => p.investisseur_id)).size > 1;
|
||||
|
||||
/* ── Calcul valeur ligne reçue ── */
|
||||
const recuRowValue = (r) => {
|
||||
let v = 0;
|
||||
if (inclureInterets) v += netMode ? r.interets_nets : r.interets_bruts;
|
||||
if (inclureCashback) v += r.cashback ?? 0;
|
||||
if (inclureCapital) v += r.capital ?? 0;
|
||||
return v;
|
||||
};
|
||||
|
||||
/* ── Calcul valeur ligne projetée ── */
|
||||
const projRowValue = (p) => {
|
||||
let v = 0;
|
||||
if (inclureInterets) v += netMode ? p.interets_prevus * (1 - pfuReduction) : p.interets_prevus;
|
||||
if (inclureCapital) v += p.capital_prevu ?? 0;
|
||||
return v;
|
||||
};
|
||||
|
||||
/* ── Totaux ── */
|
||||
const totalRecus = recus.reduce((s, r) => s + recuRowValue(r), 0);
|
||||
const totalProjetes = projetes.reduce((s, p) => s + projRowValue(p), 0);
|
||||
const grandTotal = (showRecus ? totalRecus : 0) + (showProjetes ? totalProjetes : 0);
|
||||
|
||||
/* ── Colonnes dynamiques ── */
|
||||
const cols = [
|
||||
inclureInterets && { key: 'interets', label: netMode ? 'Intérêts nets' : 'Intérêts bruts', color: chartInterets },
|
||||
inclureCapital && { key: 'capital', label: 'Capital', color: chartCapital },
|
||||
inclureCashback && { key: 'cashback', label: 'Cashback', color: chartCashback },
|
||||
].filter(Boolean);
|
||||
|
||||
/* Date + Plateforme(opt) + Projet + Détenteur + cols + Total */
|
||||
const colCount = 3 + (showPlatCol ? 1 : 0) + cols.length + 1;
|
||||
|
||||
/* Titre du header */
|
||||
const headerTitle = filterPlatId
|
||||
? `${plateformes?.find(p => String(p.id) === filterPlatId)?.nom ?? 'Plateforme'} — ${cell.moisLabel} ${cell.annee}`
|
||||
: `Toutes les plateformes — ${cell.moisLabel} ${cell.annee}`;
|
||||
|
||||
/* ── Bulk : construction des payloads ── */
|
||||
const openBulkModal = () => {
|
||||
if (!projetes.length) return;
|
||||
const items = projetes.map(p => {
|
||||
const inv = investissements?.find(i => i.id === p.investissement_id);
|
||||
const plat = inv ? plateformes?.find(pl => pl.id === inv.plateforme_id) : null;
|
||||
const bruts = p.interets_prevus || 0;
|
||||
const year = p.date_prevue ? Number(p.date_prevue.slice(0, 4)) : cell.annee;
|
||||
const rates = pfuRates?.find(r => r.annee === year)
|
||||
?? (pfuRates?.length ? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]) : null);
|
||||
const methode = plat && plat.methode_remboursement !== 'choix_investisseur'
|
||||
? plat.methode_remboursement
|
||||
: (inv?.methode_remboursement || 'portefeuille');
|
||||
const hasLocalTax = plat?.fiscalite === 'avec_fiscalite_locale' && plat?.taux_fiscalite_locale;
|
||||
const taxe_locale = hasLocalTax ? round2(bruts * plat.taux_fiscalite_locale / 100) : 0;
|
||||
const brutsApresLocal = hasLocalTax ? round2(bruts - taxe_locale) : bruts;
|
||||
return {
|
||||
_label: p.nom_projet,
|
||||
_plat: p.plateforme_nom,
|
||||
_date: p.date_prevue,
|
||||
_capital: p.capital_prevu || 0,
|
||||
_interets: bruts,
|
||||
_total: (p.capital_prevu || 0) + bruts,
|
||||
investissement_id: p.investissement_id,
|
||||
date_remb: p.date_prevue,
|
||||
capital: p.capital_prevu || 0,
|
||||
cashback: 0,
|
||||
interets_bruts_avant_local: hasLocalTax ? bruts : 0,
|
||||
taxe_locale,
|
||||
interets_bruts: brutsApresLocal,
|
||||
prelev_sociaux: rates ? round2(brutsApresLocal * rates.prelev_sociaux / 100) : 0,
|
||||
prelev_forfaitaire: rates ? round2(brutsApresLocal * rates.impot_revenu / 100) : 0,
|
||||
statut: 'paye',
|
||||
notes: '',
|
||||
methode_remboursement: methode,
|
||||
compte_id: methode === 'compte_courant' ? (inv?.compte_id || '') : '',
|
||||
};
|
||||
});
|
||||
setBulkItems(items);
|
||||
setBulkProgress(0);
|
||||
setBulkDone(false);
|
||||
setBulkProcessing(false);
|
||||
setBulkModal(true);
|
||||
};
|
||||
|
||||
const runBulk = async () => {
|
||||
setBulkProcessing(true);
|
||||
let done = 0;
|
||||
for (const item of bulkItems) {
|
||||
const { _label, _plat, _date, _capital, _interets, _total, ...payload } = item;
|
||||
try { await api.post('/remboursements', payload); } catch (e) { /* continuer */ }
|
||||
done++;
|
||||
setBulkProgress(done);
|
||||
}
|
||||
setBulkProcessing(false);
|
||||
setBulkDone(true);
|
||||
if (onBulkDone) onBulkDone();
|
||||
};
|
||||
|
||||
const closeBulkModal = () => {
|
||||
setBulkModal(false);
|
||||
setBulkItems([]);
|
||||
setBulkProgress(0);
|
||||
setBulkDone(false);
|
||||
setBulkProcessing(false);
|
||||
};
|
||||
|
||||
/* ── Rendu ── */
|
||||
return (
|
||||
<div style={{
|
||||
margin: alwaysOpen ? '0 24px 28px' : '0 24px 28px',
|
||||
padding: 0,
|
||||
border: '2px solid var(--primary)',
|
||||
borderRadius: 10,
|
||||
overflow: 'hidden',
|
||||
overflowX: 'auto',
|
||||
background: 'var(--surface)',
|
||||
}}>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '12px 16px',
|
||||
background: 'var(--primary)',
|
||||
color: '#fff',
|
||||
}}>
|
||||
<strong style={{ fontSize: '1rem' }}>{headerTitle}</strong>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{[
|
||||
{ active: inclureInterets, toggle: () => setInclureInterets(v => !v), label: netMode ? 'Intérêts nets' : 'Intérêts bruts' },
|
||||
{ active: inclureCapital, toggle: () => setInclureCapital(v => !v), label: 'Capital' },
|
||||
{ active: inclureCashback, toggle: () => setInclureCashback(v => !v), label: 'Cashback' },
|
||||
].map(btn => (
|
||||
<button key={btn.label} onClick={() => btn.toggle()} style={{
|
||||
border: `1px solid ${btn.active ? 'rgba(255,255,255,0.8)' : 'rgba(255,255,255,0.3)'}`,
|
||||
background: btn.active ? 'rgba(255,255,255,0.2)' : 'transparent',
|
||||
borderRadius: 6, padding: '3px 10px',
|
||||
fontSize: '0.8rem', fontWeight: btn.active ? 700 : 400,
|
||||
color: '#fff', cursor: 'pointer', transition: 'all .15s',
|
||||
}}>{btn.label}</button>
|
||||
))}
|
||||
{!alwaysOpen && (
|
||||
<button onClick={onClose} title="Fermer" style={{
|
||||
background: 'rgba(255,255,255,0.2)', border: '1px solid rgba(255,255,255,0.4)',
|
||||
borderRadius: 6, padding: '3px 10px', color: '#fff', cursor: 'pointer',
|
||||
fontSize: '0.85rem', fontWeight: 700,
|
||||
}}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Barre filtres ── */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
|
||||
padding: '10px 16px', borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--surface-2)',
|
||||
}}>
|
||||
{/* Sélecteur plateforme */}
|
||||
{plateformes && plateformes.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<label style={{ fontSize: '0.8rem', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Plateforme</label>
|
||||
<select
|
||||
value={filterPlatId}
|
||||
onChange={e => setFilterPlatId(e.target.value)}
|
||||
style={{ fontSize: 'var(--fs-sm)', padding: '3px 8px', borderRadius: 6,
|
||||
border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)' }}>
|
||||
<option value="">Toutes</option>
|
||||
{plateformes.map(p => <option key={p.id} value={String(p.id)}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toggle Reçus / Projetés */}
|
||||
<div style={{ display: 'inline-flex', background: 'var(--surface)', borderRadius: 8, padding: 3, gap: 2, marginLeft: 'auto' }}>
|
||||
{[
|
||||
{ key: 'recus', label: 'Reçus', active: showRecus, toggle: () => setShowRecus(v => !v) },
|
||||
{ key: 'projetes', label: 'Projetés', active: showProjetes, toggle: () => setShowProjetes(v => !v) },
|
||||
].map(btn => (
|
||||
<button key={btn.key} onClick={() => btn.toggle()} style={{
|
||||
border: 'none', cursor: 'pointer', padding: '4px 14px', borderRadius: 6,
|
||||
fontSize: 'var(--fs-sm)',
|
||||
fontWeight: btn.active ? 600 : 400,
|
||||
background: btn.active ? 'var(--primary)' : 'transparent',
|
||||
color: btn.active ? '#fff' : 'var(--text-muted)',
|
||||
boxShadow: btn.active ? '0 1px 3px rgba(0,0,0,0.13)' : 'none',
|
||||
transition: 'all .15s',
|
||||
}}>{btn.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Corps ── */}
|
||||
{loading && (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>Chargement…</div>
|
||||
)}
|
||||
|
||||
{!loading && data && (
|
||||
<div>
|
||||
<table className="drill-table" style={{ width: '100%', minWidth: '100%', fontSize: 'var(--fs-sm)' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--surface-2)', borderBottom: '1px solid var(--border)' }}>
|
||||
<th style={thStyle}>Date</th>
|
||||
{showPlatCol && <th style={thStyle}>Plateforme</th>}
|
||||
<th style={{ ...thStyle, width: "100%" }}>Projet</th>
|
||||
<th style={thStyle}>Détenteur</th>
|
||||
{cols.map(c => (
|
||||
<th key={c.key} style={{ ...thStyle, ...numStyle, color: c.color }}>{c.label}</th>
|
||||
))}
|
||||
<th style={{ ...thStyle, ...numStyle }}>Total ligne</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{/* ── Section Reçus ── */}
|
||||
{showRecus && (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={colCount} style={{
|
||||
padding: '6px 12px', fontWeight: 700, fontSize: '0.78rem',
|
||||
background: hexToRgba(chartInterets, 0.07),
|
||||
color: 'var(--text-muted)', letterSpacing: '.04em', textTransform: 'uppercase',
|
||||
}}>
|
||||
Reçus ({recus.length})
|
||||
</td>
|
||||
</tr>
|
||||
{recus.length === 0 ? (
|
||||
<tr><td colSpan={colCount} style={{ padding: '10px 16px', color: 'var(--text-muted)', fontStyle: 'italic' }}>
|
||||
Aucun remboursement reçu ce mois
|
||||
</td></tr>
|
||||
) : recus.map(r => (
|
||||
<tr key={r.id}
|
||||
style={{ borderBottom: '1px solid var(--border)', cursor: onEditRecu ? 'pointer' : 'default' }}
|
||||
title={onEditRecu ? 'Modifier ce remboursement' : undefined}
|
||||
onClick={() => onEditRecu && onEditRecu(r)}
|
||||
>
|
||||
<td style={tdStyle}>{fmtDate(r.date_remb)}</td>
|
||||
{showPlatCol && <td style={{ ...tdStyle, fontWeight: 500 }}>{r.plateforme_nom || '—'}</td>}
|
||||
<td style={tdStyle}>{r.nom_projet}</td>
|
||||
<td style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: '0.8em' }}>{r.detenteur_nom || '—'}</td>
|
||||
{cols.map(c => {
|
||||
let v;
|
||||
if (c.key === 'interets') v = netMode ? r.interets_nets : r.interets_bruts;
|
||||
else if (c.key === 'capital') v = r.capital ?? 0;
|
||||
else v = r.cashback ?? 0;
|
||||
return <td key={c.key} style={{ ...tdStyle, ...numStyle }}>{fmtEUR(v)}</td>;
|
||||
})}
|
||||
<td style={{ ...tdStyle, ...numStyle, fontWeight: 600 }}>{fmtEUR(recuRowValue(r))}</td>
|
||||
</tr>
|
||||
))}
|
||||
{recus.length > 0 && (
|
||||
<tr className="drill-row-fixed" style={{ background: 'var(--surface-2)', fontWeight: 700 }}>
|
||||
<td colSpan={3 + (showPlatCol ? 1 : 0)} style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: '0.8em' }}>
|
||||
Sous-total reçus
|
||||
</td>
|
||||
{cols.map(c => {
|
||||
const sum = recus.reduce((s, r) => {
|
||||
if (c.key === 'interets') return s + (netMode ? r.interets_nets : r.interets_bruts);
|
||||
if (c.key === 'capital') return s + (r.capital ?? 0);
|
||||
return s + (r.cashback ?? 0);
|
||||
}, 0);
|
||||
return <td key={c.key} style={{ ...tdStyle, ...numStyle }}>{fmtEUR(sum)}</td>;
|
||||
})}
|
||||
<td style={{ ...tdStyle, ...numStyle }}>{fmtEUR(totalRecus)}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
)}
|
||||
|
||||
{/* ── Section Projetés ── */}
|
||||
{showProjetes && (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={colCount} style={{
|
||||
padding: '6px 12px', fontWeight: 700, fontSize: '0.78rem',
|
||||
background: 'rgba(148,163,184,0.08)',
|
||||
color: 'var(--text-muted)', letterSpacing: '.04em', textTransform: 'uppercase',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span>Projetés ({projetes.length})</span>
|
||||
{projetes.length > 0 && (
|
||||
<button
|
||||
onClick={() => openBulkModal()}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
padding: '3px 10px', borderRadius: 5, cursor: 'pointer',
|
||||
fontSize: '0.75rem', fontWeight: 600, letterSpacing: 'normal',
|
||||
textTransform: 'none',
|
||||
border: '1px solid var(--primary)',
|
||||
background: 'var(--primary)', color: '#fff',
|
||||
transition: 'opacity .15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
Valider en masse
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{projetes.length === 0 ? (
|
||||
<tr><td colSpan={colCount} style={{ padding: '10px 16px', color: 'var(--text-muted)', fontStyle: 'italic' }}>
|
||||
Aucune projection ce mois
|
||||
</td></tr>
|
||||
) : projetes.map(p => (
|
||||
<tr key={p.id}
|
||||
style={{ borderBottom: '1px solid var(--border)', opacity: 0.85, cursor: onEditProjet ? 'pointer' : 'default' }}
|
||||
title={onEditProjet ? 'Saisir ce remboursement' : undefined}
|
||||
onClick={() => onEditProjet && onEditProjet(p)}
|
||||
>
|
||||
<td style={{ ...tdStyle, fontStyle: 'italic', color: 'var(--text-muted)' }}>{fmtDate(p.date_prevue)}</td>
|
||||
{showPlatCol && <td style={{ ...tdStyle, fontStyle: 'italic', fontWeight: 500 }}>{p.plateforme_nom || '—'}</td>}
|
||||
<td style={{ ...tdStyle, fontStyle: 'italic' }}>{p.nom_projet}</td>
|
||||
<td style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: '0.8em' }}>{p.detenteur_nom || '—'}</td>
|
||||
{cols.map(c => {
|
||||
let v;
|
||||
if (c.key === 'interets') v = netMode ? p.interets_prevus * (1 - pfuReduction) : p.interets_prevus;
|
||||
else if (c.key === 'capital') v = p.capital_prevu ?? 0;
|
||||
else v = 0;
|
||||
return <td key={c.key} style={{ ...tdStyle, ...numStyle, fontStyle: 'italic', color: 'var(--text-muted)' }}>{fmtEUR(v)}</td>;
|
||||
})}
|
||||
<td style={{ ...tdStyle, ...numStyle, fontStyle: 'italic', color: 'var(--text-muted)' }}>{fmtEUR(projRowValue(p))}</td>
|
||||
</tr>
|
||||
))}
|
||||
{projetes.length > 0 && (
|
||||
<tr className="drill-row-fixed" style={{ background: 'var(--surface-2)', fontWeight: 700 }}>
|
||||
<td colSpan={3 + (showPlatCol ? 1 : 0)} style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: '0.8em' }}>
|
||||
Sous-total projetés
|
||||
</td>
|
||||
{cols.map(c => {
|
||||
const sum = projetes.reduce((s, p) => {
|
||||
if (c.key === 'interets') return s + (netMode ? p.interets_prevus * (1 - pfuReduction) : p.interets_prevus);
|
||||
if (c.key === 'capital') return s + (p.capital_prevu ?? 0);
|
||||
return s;
|
||||
}, 0);
|
||||
return <td key={c.key} style={{ ...tdStyle, ...numStyle }}>{fmtEUR(sum)}</td>;
|
||||
})}
|
||||
<td style={{ ...tdStyle, ...numStyle }}>{fmtEUR(totalProjetes)}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
)}
|
||||
|
||||
{/* ── Grand total ── */}
|
||||
<tfoot>
|
||||
<tr className="drill-row-fixed" style={{
|
||||
background: 'var(--primary)', color: '#fff',
|
||||
fontWeight: 700, fontSize: '0.9rem', pointerEvents: 'none',
|
||||
}}>
|
||||
<td colSpan={3 + (showPlatCol ? 1 : 0)} style={{ ...tdStyle, color: '#fff' }}>Total</td>
|
||||
{cols.map(c => {
|
||||
const sumR = showRecus ? recus.reduce((s, r) => {
|
||||
if (c.key === 'interets') return s + (netMode ? r.interets_nets : r.interets_bruts);
|
||||
if (c.key === 'capital') return s + (r.capital ?? 0);
|
||||
return s + (r.cashback ?? 0);
|
||||
}, 0) : 0;
|
||||
const sumP = showProjetes ? projetes.reduce((s, p) => {
|
||||
if (c.key === 'interets') return s + (netMode ? p.interets_prevus * (1 - pfuReduction) : p.interets_prevus);
|
||||
if (c.key === 'capital') return s + (p.capital_prevu ?? 0);
|
||||
return s;
|
||||
}, 0) : 0;
|
||||
return <td key={c.key} style={{ ...tdStyle, ...numStyle, color: '#fff' }}>{fmtEUR(sumR + sumP)}</td>;
|
||||
})}
|
||||
<td style={{ ...tdStyle, ...numStyle, color: '#fff', fontSize: '1rem' }}>
|
||||
{fmtEUR(grandTotal)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Modale validation en masse ── */}
|
||||
{bulkModal && (
|
||||
<Modal
|
||||
open={bulkModal}
|
||||
title={bulkDone ? 'Validation terminée' : `Valider ${bulkItems.length} remboursement${bulkItems.length > 1 ? 's' : ''}`}
|
||||
onClose={bulkProcessing ? undefined : closeBulkModal}
|
||||
width={620}
|
||||
footer={
|
||||
bulkDone ? (
|
||||
<button className="btn-primary" onClick={() => closeBulkModal()}>Fermer</button>
|
||||
) : bulkProcessing ? (
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>
|
||||
Traitement en cours… {bulkProgress}/{bulkItems.length}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn-secondary" onClick={() => closeBulkModal()}>Annuler</button>
|
||||
<button className="btn-primary" onClick={() => runBulk()}>
|
||||
Enregistrer {bulkItems.length} remboursement{bulkItems.length > 1 ? 's' : ''}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{bulkProcessing && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ height: 6, borderRadius: 3, background: 'var(--border)', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%', borderRadius: 3, background: 'var(--primary)',
|
||||
width: `${bulkItems.length > 0 ? (bulkProgress / bulkItems.length) * 100 : 0}%`,
|
||||
transition: 'width .3s ease',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{bulkDone && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, padding: '12px 0' }}>
|
||||
<div style={{ width: 48, height: 48, borderRadius: '50%', background: 'var(--success)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p style={{ margin: 0, fontWeight: 600, fontSize: '1rem' }}>
|
||||
{bulkItems.length} remboursement{bulkItems.length > 1 ? 's' : ''} enregistré{bulkItems.length > 1 ? 's' : ''}
|
||||
</p>
|
||||
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.88rem' }}>
|
||||
{cell.moisLabel} {cell.annee}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!bulkDone && (
|
||||
<>
|
||||
<p style={{ margin: '0 0 12px', color: 'var(--text-muted)', fontSize: '0.88rem' }}>
|
||||
Les remboursements suivants vont être créés d'après les projections de <strong>{cell.moisLabel} {cell.annee}</strong>.
|
||||
Les prélèvements fiscaux sont estimés à partir des taux PFU de l'année.
|
||||
</p>
|
||||
<div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.88rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--surface-2)', borderBottom: '1px solid var(--border)' }}>
|
||||
{showPlatCol && <th style={thStyle}>Plateforme</th>}
|
||||
<th style={{ ...thStyle, width: "100%" }}>Projet</th>
|
||||
<th style={{ ...thStyle, ...numStyle }}>Date</th>
|
||||
<th style={{ ...thStyle, ...numStyle }}>Capital</th>
|
||||
<th style={{ ...thStyle, ...numStyle }}>Intérêts bruts</th>
|
||||
<th style={{ ...thStyle, ...numStyle }}>Total brut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bulkItems.map((item, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
{showPlatCol && <td style={tdStyle}>{item._plat || '—'}</td>}
|
||||
<td style={tdStyle}>{item._label}</td>
|
||||
<td style={{ ...tdStyle, ...numStyle }}>{fmtDate(item._date)}</td>
|
||||
<td style={{ ...tdStyle, ...numStyle }}>{fmtEUR(item._capital)}</td>
|
||||
<td style={{ ...tdStyle, ...numStyle }}>{fmtEUR(item._interets)}</td>
|
||||
<td style={{ ...tdStyle, ...numStyle, fontWeight: 600 }}>{fmtEUR(item._total)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr style={{ background: 'var(--surface-2)', fontWeight: 700 }}>
|
||||
<td colSpan={showPlatCol ? 5 : 4} style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: '0.8em' }}>Total</td>
|
||||
<td style={{ ...tdStyle, ...numStyle }}>{fmtEUR(bulkItems.reduce((s, i) => s + i._total, 0))}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Styles partagés ── */
|
||||
const thStyle = {
|
||||
padding: '7px 12px',
|
||||
textAlign: 'left',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.78rem',
|
||||
letterSpacing: '.03em',
|
||||
color: 'var(--text-muted)',
|
||||
whiteSpace: 'nowrap',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
};
|
||||
const tdStyle = { padding: '7px 12px', verticalAlign: 'middle', whiteSpace: 'nowrap' };
|
||||
const numStyle = { textAlign: 'right' };
|
||||
@@ -0,0 +1,285 @@
|
||||
import { useMemo, useState, useRef } from 'react';
|
||||
|
||||
/* ── Constantes ─────────────────────────────────────────────── */
|
||||
const COLOR = '#4fa8e8';
|
||||
const BG = '#070c15';
|
||||
const GRID = 'rgba(255,255,255,0.055)';
|
||||
const LABEL = '#4a5568';
|
||||
|
||||
const RANGES = ['1J', '7J', '1M', '3M', 'YTD', '1A', 'TOUT'];
|
||||
const MOIS_COURT = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.'];
|
||||
|
||||
/* ── Helpers ────────────────────────────────────────────────── */
|
||||
function fmtK(v) {
|
||||
if (v === 0) return '0 €';
|
||||
const abs = Math.abs(v);
|
||||
if (abs >= 1000) return (v / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 1 }) + ' k €';
|
||||
return v.toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' €';
|
||||
}
|
||||
|
||||
function fmtAxisDate(dateStr, range) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const mon = MOIS_COURT[d.getMonth()];
|
||||
const yr = d.getFullYear();
|
||||
if (range === '1J' || range === '7J') return `${day}/${String(d.getMonth()+1).padStart(2,'0')}`;
|
||||
if (range === '1M' || range === '3M') return `${day} ${mon}`;
|
||||
return `${day}/${String(d.getMonth()+1).padStart(2,'0')}/${String(yr).slice(2)}`;
|
||||
}
|
||||
|
||||
function fmtValueDisplay(v) {
|
||||
return v.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €';
|
||||
}
|
||||
|
||||
function fmtTodayFull() {
|
||||
return new Date().toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
/* ── Composant ──────────────────────────────────────────────── */
|
||||
export default function InteretsChart({ rows, netMode }) {
|
||||
const [range, setRange] = useState('TOUT');
|
||||
const [hover, setHover] = useState(null);
|
||||
const svgRef = useRef(null);
|
||||
const wrapRef = useRef(null);
|
||||
|
||||
/* ── 1. Cumul complet ── */
|
||||
const allPoints = useMemo(() => {
|
||||
if (!rows?.length) return [];
|
||||
const byDate = {};
|
||||
for (const r of rows) {
|
||||
const d = r.date_remb.slice(0, 10);
|
||||
const val = netMode ? (r.interets_nets || 0) : (r.interets_bruts || 0);
|
||||
byDate[d] = (byDate[d] || 0) + val;
|
||||
}
|
||||
let cum = 0;
|
||||
return Object.keys(byDate).sort().map(date => {
|
||||
cum += byDate[date];
|
||||
return { date, value: cum };
|
||||
});
|
||||
}, [rows, netMode]);
|
||||
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
const currentValue = allPoints.length ? allPoints[allPoints.length - 1].value : 0;
|
||||
|
||||
/* ── 2. Filtrage par plage ── */
|
||||
const points = useMemo(() => {
|
||||
if (!allPoints.length) return [];
|
||||
if (range === 'TOUT') {
|
||||
const pts = [...allPoints];
|
||||
if (pts[pts.length - 1].date < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value });
|
||||
return pts;
|
||||
}
|
||||
const now = new Date();
|
||||
let fromDate = new Date(now);
|
||||
switch (range) {
|
||||
case '1J': fromDate.setDate(now.getDate() - 1); break;
|
||||
case '7J': fromDate.setDate(now.getDate() - 7); break;
|
||||
case '1M': fromDate.setMonth(now.getMonth() - 1); break;
|
||||
case '3M': fromDate.setMonth(now.getMonth() - 3); break;
|
||||
case 'YTD': fromDate = new Date(now.getFullYear(), 0, 1); break;
|
||||
case '1A': fromDate.setFullYear(now.getFullYear() - 1); break;
|
||||
}
|
||||
const fromStr = fromDate.toISOString().slice(0, 10);
|
||||
const before = allPoints.filter(p => p.date < fromStr);
|
||||
const startV = before.length ? before[before.length - 1].value : 0;
|
||||
const after = allPoints.filter(p => p.date >= fromStr);
|
||||
const pts = [{ date: fromStr, value: startV }, ...after];
|
||||
if (pts[pts.length - 1].date < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value });
|
||||
return pts;
|
||||
}, [allPoints, range, todayStr]);
|
||||
|
||||
/* ── 3. SVG dimensions ── */
|
||||
const W = 900, H = 260;
|
||||
const PAD = { top: 16, right: 16, bottom: 32, left: 70 };
|
||||
const plotW = W - PAD.left - PAD.right;
|
||||
const plotH = H - PAD.top - PAD.bottom;
|
||||
|
||||
/* ── 4. Échelles ── */
|
||||
const { xScale, yScale, yTicks, xTicks, minDate, maxDate, yZero } = useMemo(() => {
|
||||
if (points.length < 2) return {};
|
||||
const vals = points.map(p => p.value);
|
||||
const dataMin = Math.min(...vals);
|
||||
const dataMax = Math.max(...vals);
|
||||
const lo = Math.min(0, dataMin);
|
||||
const hi = Math.max(0, dataMax);
|
||||
const pad = (hi - lo) * 0.1 || 1;
|
||||
const scaleLo = lo - (lo < 0 ? pad : 0);
|
||||
const scaleHi = hi + pad;
|
||||
const valRange = scaleHi - scaleLo || 1;
|
||||
|
||||
const ts = points.map(p => new Date(p.date + 'T00:00:00').getTime());
|
||||
const minDt = ts[0];
|
||||
const maxDt = ts[ts.length - 1];
|
||||
const dtRange = maxDt - minDt || 1;
|
||||
|
||||
const xScale = d => PAD.left + ((new Date(d + 'T00:00:00').getTime() - minDt) / dtRange) * plotW;
|
||||
const yScale = v => PAD.top + plotH - ((v - scaleLo) / valRange) * plotH;
|
||||
|
||||
const step = (scaleHi - scaleLo) / 4;
|
||||
const yTicks = Array.from({ length: 5 }, (_, i) => ({ v: scaleLo + step * i, y: yScale(scaleLo + step * i) }));
|
||||
|
||||
const nX = Math.min(8, points.length);
|
||||
const xTicks = Array.from({ length: nX }, (_, i) => {
|
||||
const idx = Math.round((i / (nX - 1)) * (points.length - 1));
|
||||
return points[idx];
|
||||
});
|
||||
|
||||
return { xScale, yScale, yTicks, xTicks, minDate: minDt, maxDate: maxDt, yZero: yScale(0) };
|
||||
}, [points, plotW, plotH]);
|
||||
|
||||
/* ── 5. Chemins SVG ── */
|
||||
const { linePath, areaPath } = useMemo(() => {
|
||||
if (!xScale || points.length < 2) return { linePath: '', areaPath: '' };
|
||||
let line = `M ${xScale(points[0].date).toFixed(1)},${yScale(points[0].value).toFixed(1)}`;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
line += ` H ${xScale(points[i].date).toFixed(1)} V ${yScale(points[i].value).toFixed(1)}`;
|
||||
}
|
||||
const zeroY = yZero?.toFixed(1) ?? (PAD.top + plotH).toFixed(1);
|
||||
const area = `${line} V ${zeroY} H ${PAD.left} Z`;
|
||||
return { linePath: line, areaPath: area };
|
||||
}, [points, xScale, yScale, yZero]);
|
||||
|
||||
/* ── 6. Hover ── */
|
||||
const handleMouseMove = (e) => {
|
||||
if (!svgRef.current || !xScale || points.length < 2) return;
|
||||
const rect = svgRef.current.getBoundingClientRect();
|
||||
const svgX = ((e.clientX - rect.left) / rect.width) * W;
|
||||
const t = minDate + ((svgX - PAD.left) / plotW) * (maxDate - minDate);
|
||||
let nearest = points[0], minDiff = Infinity;
|
||||
for (const p of points) {
|
||||
const diff = Math.abs(new Date(p.date + 'T00:00:00').getTime() - t);
|
||||
if (diff < minDiff) { minDiff = diff; nearest = p; }
|
||||
}
|
||||
setHover({ x: xScale(nearest.date), y: yScale(nearest.value), value: nearest.value, date: nearest.date });
|
||||
};
|
||||
|
||||
const tooltipStyle = useMemo(() => {
|
||||
if (!hover) return null;
|
||||
const xPct = (hover.x / W) * 100;
|
||||
const yPct = (hover.y / H) * 100;
|
||||
const anchorRight = xPct > 65;
|
||||
return {
|
||||
position: 'absolute',
|
||||
top: `calc(${yPct}% - 64px)`,
|
||||
left: anchorRight ? 'auto' : `calc(${xPct}% + 12px)`,
|
||||
right: anchorRight ? `calc(${100 - xPct}% + 12px)` : 'auto',
|
||||
transform: 'none',
|
||||
pointerEvents: 'none',
|
||||
};
|
||||
}, [hover]);
|
||||
|
||||
if (!allPoints.length) return null;
|
||||
|
||||
const displayDate = hover
|
||||
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })
|
||||
: fmtTodayFull();
|
||||
const displayValue = hover ? fmtValueDisplay(hover.value) : fmtValueDisplay(currentValue);
|
||||
const tooltipDate = hover
|
||||
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="solde-chart-wrap" ref={wrapRef} style={{ position: 'relative' }}>
|
||||
|
||||
{/* ── En-tête ── */}
|
||||
<div className="solde-chart-header">
|
||||
<div className="solde-chart-info">
|
||||
<div className="solde-chart-date">{displayDate}</div>
|
||||
<div className="solde-chart-value">{displayValue}</div>
|
||||
</div>
|
||||
<div className="solde-chart-controls" style={{ gap: 8 }}>
|
||||
{/* Plages temporelles */}
|
||||
<div className="solde-chart-ranges">
|
||||
{RANGES.map(r => (
|
||||
<button key={r}
|
||||
className={`solde-range-btn${range === r ? ' active' : ''}`}
|
||||
onClick={() => { setRange(r); setHover(null); }}>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── SVG ── */}
|
||||
{xScale && (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
style={{ width: '100%', height: 'auto', display: 'block', cursor: 'crosshair' }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="ig-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={COLOR} stopOpacity="0.22" />
|
||||
<stop offset="70%" stopColor={COLOR} stopOpacity="0.06" />
|
||||
<stop offset="100%" stopColor={COLOR} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<filter id="ig-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Grille horizontale */}
|
||||
{yTicks.map(({ v, y }) => (
|
||||
<g key={v}>
|
||||
<line x1={PAD.left} y1={y} x2={W - PAD.right} y2={y}
|
||||
stroke={GRID} strokeWidth="1" strokeDasharray="3 5" />
|
||||
<text x={PAD.left - 10} y={y + 4} textAnchor="end"
|
||||
fill={LABEL} fontSize="11" fontFamily="system-ui,sans-serif">
|
||||
{fmtK(v)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Ligne zéro */}
|
||||
{yZero && yZero > PAD.top && yZero < PAD.top + plotH && (
|
||||
<line x1={PAD.left} y1={yZero} x2={W - PAD.right} y2={yZero}
|
||||
stroke="rgba(255,255,255,0.18)" strokeWidth="1" />
|
||||
)}
|
||||
|
||||
{/* Remplissage dégradé */}
|
||||
<path d={areaPath} fill="url(#ig-fill)" />
|
||||
|
||||
{/* Ligne principale */}
|
||||
<path d={linePath} fill="none" stroke={COLOR} strokeWidth="1"
|
||||
filter="url(#ig-glow)" strokeLinejoin="round" />
|
||||
|
||||
{/* Labels axe X */}
|
||||
{xTicks.map((p, i) => {
|
||||
const anchor = i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle';
|
||||
return (
|
||||
<text key={i} x={xScale(p.date)} y={H - 8}
|
||||
textAnchor={anchor} fill={LABEL} fontSize="10"
|
||||
fontFamily="system-ui,sans-serif">
|
||||
{fmtAxisDate(p.date, range)}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Ligne verticale + point hover */}
|
||||
{hover && (
|
||||
<g>
|
||||
<line x1={hover.x} y1={PAD.top} x2={hover.x} y2={PAD.top + plotH}
|
||||
stroke="rgba(255,255,255,0.12)" strokeWidth="1" strokeDasharray="3 4" />
|
||||
<circle cx={hover.x} cy={hover.y} r="4.5"
|
||||
fill={COLOR} stroke={BG} strokeWidth="2" />
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* ── Tooltip ── */}
|
||||
{hover && tooltipStyle && (
|
||||
<div style={tooltipStyle}>
|
||||
<div className="sg-tooltip">
|
||||
<span className="sg-tooltip-date">{tooltipDate}</span>
|
||||
<span className="sg-tooltip-value">{fmtValueDisplay(hover.value)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useMemo, useState, useRef, useCallback } from 'react';
|
||||
|
||||
/* ── Palette ────────────────────────────────────────────────── */
|
||||
const PALETTE = [
|
||||
'#e8547a', '#9333ea', '#0d9488', '#f59e0b',
|
||||
'#3b82f6', '#10b981', '#f97316', '#06b6d4',
|
||||
'#a855f7', '#84cc16', '#ec4899', '#14b8a6',
|
||||
];
|
||||
|
||||
/* ── Algorithme treemap (binary split équilibré) ─────────────── */
|
||||
function buildTreemap(items, x, y, w, h) {
|
||||
if (!items.length) return [];
|
||||
if (items.length === 1) return [{ ...items[0], x, y, w, h }];
|
||||
|
||||
const total = items.reduce((s, i) => s + i.value, 0);
|
||||
let best = 1, bestDiff = Infinity, acc = 0;
|
||||
for (let i = 0; i < items.length - 1; i++) {
|
||||
acc += items[i].value;
|
||||
const diff = Math.abs(acc - (total - acc));
|
||||
if (diff < bestDiff) { bestDiff = diff; best = i + 1; }
|
||||
}
|
||||
|
||||
const g1 = items.slice(0, best);
|
||||
const g2 = items.slice(best);
|
||||
const r1 = g1.reduce((s, i) => s + i.value, 0) / total;
|
||||
|
||||
if (w >= h) {
|
||||
const w1 = w * r1;
|
||||
return [...buildTreemap(g1, x, y, w1, h), ...buildTreemap(g2, x + w1, y, w - w1, h)];
|
||||
} else {
|
||||
const h1 = h * r1;
|
||||
return [...buildTreemap(g1, x, y, w, h1), ...buildTreemap(g2, x, y + h1, w, h - h1)];
|
||||
}
|
||||
}
|
||||
|
||||
function fmtAmount(v) {
|
||||
return Math.round(v).toLocaleString('fr-FR') + ' €';
|
||||
}
|
||||
|
||||
function wrapText(text, maxWidth, fontSize) {
|
||||
const charW = fontSize * 0.58;
|
||||
const maxChars = Math.max(1, Math.floor(maxWidth / charW));
|
||||
const words = text.split(' ');
|
||||
const lines = [];
|
||||
let current = '';
|
||||
for (const word of words) {
|
||||
const candidate = current ? current + ' ' + word : word;
|
||||
if (candidate.length <= maxChars) {
|
||||
current = candidate;
|
||||
} else {
|
||||
if (current) lines.push(current);
|
||||
current = word.length > maxChars ? word.slice(0, maxChars - 1) + '…' : word;
|
||||
}
|
||||
}
|
||||
if (current) lines.push(current);
|
||||
return lines.slice(0, 3);
|
||||
}
|
||||
|
||||
/* ── Composant ──────────────────────────────────────────────── */
|
||||
export default function InteretsDistributionChart({ rows, netMode }) {
|
||||
const [hoveredIdx, setHoveredIdx] = useState(null);
|
||||
const [tooltip, setTooltip] = useState(null);
|
||||
const svgRef = useRef(null);
|
||||
const wrapRef = useRef(null);
|
||||
|
||||
/* Intérêts cumulés par plateforme */
|
||||
const data = useMemo(() => {
|
||||
if (!rows?.length) return [];
|
||||
const byPlat = {};
|
||||
for (const r of rows) {
|
||||
const key = r.plateforme_nom || `#${r.plateforme_id}`;
|
||||
const val = netMode ? (r.interets_nets || 0) : (r.interets_bruts || 0);
|
||||
byPlat[key] = (byPlat[key] || 0) + val;
|
||||
}
|
||||
return Object.entries(byPlat)
|
||||
.filter(([, v]) => v > 0)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([name, value], i) => ({ name, value, color: PALETTE[i % PALETTE.length] }));
|
||||
}, [rows, netMode]);
|
||||
|
||||
const total = data.reduce((s, i) => s + i.value, 0);
|
||||
const W = 440, H = 290, GAP = 3;
|
||||
const cells = useMemo(() => buildTreemap(data, 0, 0, W, H), [data]);
|
||||
|
||||
const handleMouseMove = useCallback((e, cell, idx) => {
|
||||
if (!wrapRef.current) return;
|
||||
const wrapRect = wrapRef.current.getBoundingClientRect();
|
||||
setHoveredIdx(idx);
|
||||
setTooltip({ x: e.clientX - wrapRect.left, y: e.clientY - wrapRect.top, cell });
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setHoveredIdx(null);
|
||||
setTooltip(null);
|
||||
}, []);
|
||||
|
||||
if (!cells.length) return null;
|
||||
|
||||
const TIP_W = 160, TIP_H = 66;
|
||||
const tipStyle = tooltip ? (() => {
|
||||
const ww = wrapRef.current?.offsetWidth || 440;
|
||||
const wh = wrapRef.current?.offsetHeight || 360;
|
||||
let tx = tooltip.x + 14;
|
||||
let ty = tooltip.y - TIP_H / 2;
|
||||
if (tx + TIP_W > ww - 4) tx = tooltip.x - TIP_W - 14;
|
||||
if (ty < 4) ty = 4;
|
||||
if (ty + TIP_H > wh - 4) ty = wh - TIP_H - 4;
|
||||
return { left: tx, top: ty };
|
||||
})() : null;
|
||||
|
||||
const modeLabel = netMode ? 'Nets' : 'Bruts';
|
||||
|
||||
return (
|
||||
<div className="dist-chart-wrap" ref={wrapRef}>
|
||||
|
||||
{/* ── En-tête ── */}
|
||||
<div className="dist-chart-header">
|
||||
<span className="dist-chart-title">Intérêts {modeLabel}</span>
|
||||
<div className="dist-dropdown">
|
||||
par Plateforme
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Treemap SVG ── */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
style={{ width: '100%', height: 'auto', display: 'block' }}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="idt-shadow" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#000" stopOpacity="0" />
|
||||
<stop offset="100%" stopColor="#000" stopOpacity="0.4" />
|
||||
</linearGradient>
|
||||
<filter id="idt-txt-shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="1.5" floodColor="#000" floodOpacity="0.55"/>
|
||||
</filter>
|
||||
{cells.map((cell, i) => {
|
||||
const PAD = 4;
|
||||
const gx = cell.x + GAP / 2 + PAD;
|
||||
const gy = cell.y + GAP / 2 + PAD;
|
||||
const gw = Math.max(cell.w - GAP - PAD * 2, 0);
|
||||
const gh = Math.max(cell.h - GAP - PAD * 2, 0);
|
||||
return (
|
||||
<clipPath key={i} id={`idt-clip-${i}`}>
|
||||
<rect x={gx} y={gy} width={gw} height={gh} />
|
||||
</clipPath>
|
||||
);
|
||||
})}
|
||||
</defs>
|
||||
|
||||
{cells.map((cell, i) => {
|
||||
const gx = cell.x + GAP / 2;
|
||||
const gy = cell.y + GAP / 2;
|
||||
const gw = Math.max(cell.w - GAP, 0);
|
||||
const gh = Math.max(cell.h - GAP, 0);
|
||||
const cx = gx + gw / 2;
|
||||
const cy = gy + gh / 2;
|
||||
const pct = ((cell.value / total) * 100).toFixed(0);
|
||||
const amt = fmtAmount(cell.value);
|
||||
const isHovered = hoveredIdx === i;
|
||||
|
||||
const fsAmt = Math.min(15, Math.max(10, gw / 7.5));
|
||||
const fsName = Math.min(12, Math.max(8, gw / 9.5));
|
||||
const fsPct = Math.min(11, Math.max(7, gw / 11));
|
||||
const lineH = 15;
|
||||
|
||||
const canShowAmt = gw > 36 && gh > 20;
|
||||
const isLandscape = gw > gh;
|
||||
const combinedText = `${cell.name} — ${pct} %`;
|
||||
const combinedCharW = fsName * 0.58;
|
||||
const combinedFits = isLandscape && (combinedText.length * combinedCharW) <= (gw - 10);
|
||||
const nameLines = (gw > 40 && gh > 30) ? wrapText(cell.name, gw - 10, fsName) : [];
|
||||
const canShowName = nameLines.length > 0;
|
||||
const CLIP_PAD = 4;
|
||||
const textHeightSoFar = (canShowAmt ? lineH : 0) + (canShowName ? nameLines.length * lineH : 0);
|
||||
const canShowPct = !combinedFits && gw > 44 && (gh - CLIP_PAD * 2) >= textHeightSoFar + lineH;
|
||||
|
||||
const textItems = [];
|
||||
{
|
||||
const slots = [];
|
||||
if (canShowAmt) slots.push({ type: 'amt', h: lineH + 2 });
|
||||
if (combinedFits) {
|
||||
slots.push({ type: 'combined', text: combinedText, h: lineH });
|
||||
} else {
|
||||
if (canShowName) nameLines.forEach((l, li) => slots.push({ type: 'name', li, text: l, h: lineH }));
|
||||
if (canShowPct) slots.push({ type: 'pct', h: lineH });
|
||||
}
|
||||
const totalH = slots.reduce((s, sl) => s + sl.h, 0);
|
||||
let y = cy - totalH / 2 + lineH / 2;
|
||||
for (const sl of slots) {
|
||||
if (sl.type === 'amt') textItems.push({ key: 'amt', text: amt, fs: fsAmt, fw: '700', op: 1, y });
|
||||
if (sl.type === 'combined') textItems.push({ key: 'combined', text: sl.text, fs: fsName, fw: '500', op: 0.92, y });
|
||||
if (sl.type === 'name') textItems.push({ key: `n${sl.li}`, text: sl.text, fs: fsName, fw: '500', op: 0.92, y });
|
||||
if (sl.type === 'pct') textItems.push({ key: 'pct', text: pct + ' %', fs: fsPct, fw: '400', op: 0.75, y });
|
||||
y += sl.h;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<g key={i} style={{ cursor: 'pointer' }}
|
||||
onMouseMove={e => handleMouseMove(e, { ...cell, pct, amt }, i)}
|
||||
>
|
||||
<rect x={gx} y={gy} width={gw} height={gh}
|
||||
rx="5" fill={cell.color}
|
||||
opacity={hoveredIdx !== null && !isHovered ? 0.45 : 0.9}
|
||||
style={{ transition: 'opacity .15s' }}
|
||||
/>
|
||||
<rect x={gx} y={gy} width={gw} height={gh}
|
||||
rx="5" fill="url(#idt-shadow)" opacity="0.35" />
|
||||
{isHovered && (
|
||||
<rect x={gx} y={gy} width={gw} height={gh}
|
||||
rx="5" fill="none"
|
||||
stroke="rgba(255,255,255,0.55)" strokeWidth="1.5" />
|
||||
)}
|
||||
<g clipPath={`url(#idt-clip-${i})`}>
|
||||
{textItems.map(l => (
|
||||
<text key={l.key} x={cx} y={l.y}
|
||||
textAnchor="middle" dominantBaseline="middle"
|
||||
fill="white"
|
||||
fillOpacity={hoveredIdx !== null && !isHovered ? 0.3 : l.op}
|
||||
fontSize={l.fs} fontWeight={l.fw}
|
||||
fontFamily="system-ui,-apple-system,sans-serif"
|
||||
filter="url(#idt-txt-shadow)"
|
||||
style={{ transition: 'fill-opacity .15s' }}>
|
||||
{l.text}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* ── Tooltip ── */}
|
||||
{tooltip && tipStyle && (
|
||||
<div className="dist-tooltip" style={{ left: tipStyle.left, top: tipStyle.top }}>
|
||||
<div className="dist-tooltip-dot" style={{ background: tooltip.cell.color }} />
|
||||
<div>
|
||||
<div className="dist-tooltip-amt">{tooltip.cell.amt}</div>
|
||||
<div className="dist-tooltip-name">{tooltip.cell.name}</div>
|
||||
<div className="dist-tooltip-pct">{tooltip.cell.pct} %</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
import { useMemo, useState, useRef } from 'react';
|
||||
import { useInteretsChart } from '../context/InteretsChartContext.jsx';
|
||||
|
||||
function fmtTotal(v) {
|
||||
return v.toLocaleString('fr-FR',{minimumFractionDigits:2,maximumFractionDigits:2})+' €';
|
||||
}
|
||||
function fmtCenter(v) {
|
||||
if (!v||v===0) return '—';
|
||||
return v.toLocaleString('fr-FR',{minimumFractionDigits:2,maximumFractionDigits:2})+' €';
|
||||
}
|
||||
|
||||
/* ── Helpers SVG ──────────────────────────────────────────────── */
|
||||
function polar(cx,cy,r,deg) {
|
||||
const rad=(deg-90)*Math.PI/180;
|
||||
return {x:cx+r*Math.cos(rad), y:cy+r*Math.sin(rad)};
|
||||
}
|
||||
|
||||
function roundedArcPath(cx, cy, outerR, innerR, startDeg, endDeg) {
|
||||
const span = endDeg - startDeg;
|
||||
if (span < 0.1) return '';
|
||||
const f = n => n.toFixed(2);
|
||||
|
||||
if (span >= 359.9) {
|
||||
const o0=polar(cx,cy,outerR,0), o1=polar(cx,cy,outerR,180);
|
||||
const i0=polar(cx,cy,innerR,0), i1=polar(cx,cy,innerR,180);
|
||||
return [
|
||||
`M${f(o0.x)} ${f(o0.y)} A${outerR} ${outerR} 0 1 1 ${f(o1.x)} ${f(o1.y)}`,
|
||||
`A${outerR} ${outerR} 0 1 1 ${f(o0.x)} ${f(o0.y)} Z`,
|
||||
`M${f(i0.x)} ${f(i0.y)} A${innerR} ${innerR} 0 1 1 ${f(i1.x)} ${f(i1.y)}`,
|
||||
`A${innerR} ${innerR} 0 1 1 ${f(i0.x)} ${f(i0.y)} Z`,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
const capR = (outerR - innerR) / 2;
|
||||
const lg = span > 180 ? 1 : 0;
|
||||
const oS=polar(cx,cy,outerR,startDeg), oE=polar(cx,cy,outerR,endDeg);
|
||||
const iS=polar(cx,cy,innerR,startDeg), iE=polar(cx,cy,innerR,endDeg);
|
||||
|
||||
return [
|
||||
`M${f(oS.x)} ${f(oS.y)}`,
|
||||
`A${outerR} ${outerR} 0 ${lg} 1 ${f(oE.x)} ${f(oE.y)}`,
|
||||
`A${capR} ${capR} 0 0 1 ${f(iE.x)} ${f(iE.y)}`,
|
||||
`A${innerR} ${innerR} 0 ${lg} 0 ${f(iS.x)} ${f(iS.y)}`,
|
||||
`A${capR} ${capR} 0 0 0 ${f(oS.x)} ${f(oS.y)}`,
|
||||
'Z',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/* ── Dimensions donut ─────────────────────────────────────────── */
|
||||
const CX=120, CY=120;
|
||||
const OUTER_R=108, INNER_R=72;
|
||||
|
||||
export default function InteretsDonutChart() {
|
||||
const {
|
||||
annee,
|
||||
inclureInterets, setInclureInterets,
|
||||
inclureCapital, setInclureCapital,
|
||||
inclureCashback, setInclureCashback,
|
||||
selectedMonth, setSelectedMonth,
|
||||
showActual, showProjected,
|
||||
selectActualOnly, selectProjectedOnly, setActualProjected,
|
||||
months,
|
||||
modeGlobal,
|
||||
selectedYear, setSelectedYear,
|
||||
years,
|
||||
chartInterets, chartCapital, chartCashback,
|
||||
netMode,
|
||||
} = useInteretsChart();
|
||||
|
||||
const [hoveredArcIdx, setHoveredArcIdx] = useState(null);
|
||||
const [hoveredLegIdx, setHoveredLegIdx] = useState(null);
|
||||
const [centerHovered, setCenterHovered] = useState(false);
|
||||
|
||||
/* ── Mémoire de filtrage ─────────────────────────────────────── */
|
||||
const prevStateRef = useRef(null);
|
||||
const [hasPrev, setHasPrev] = useState(false);
|
||||
|
||||
const activeCount = [inclureCapital, inclureCashback, inclureInterets].filter(Boolean).length;
|
||||
|
||||
/* ── Construire un item donut ────────────────────────────────── */
|
||||
const makeItem = (label, color, opacity, actualAmt, projectedAmt) => ({
|
||||
label, color, opacity,
|
||||
value: actualAmt + projectedAmt,
|
||||
actualAmt, projectedAmt,
|
||||
});
|
||||
|
||||
/* ── Source de données — ordre fixe : Intérêts → Capital → Cashback ── */
|
||||
const donutData = useMemo(() => {
|
||||
if (activeCount===0) return [];
|
||||
|
||||
if (modeGlobal) {
|
||||
const src = selectedYear !== null ? years.find(y => y.y === selectedYear) : null;
|
||||
const sumA = key => showActual ? (src ? (src[key]||0) : years.reduce((s,y) => s+(y[key]||0), 0)) : 0;
|
||||
const sumP = key => showProjected ? (src ? (src[key]||0) : years.reduce((s,y) => s+(y[key]||0), 0)) : 0;
|
||||
|
||||
if (activeCount===1) {
|
||||
if (inclureInterets) {
|
||||
const actual=sumA('interetsAmt'), proj=sumP('interetsProjAmt'), s=[];
|
||||
if(actual>0) s.push(makeItem('Reçu', chartInterets, 0.88, actual, 0));
|
||||
if(proj >0) s.push(makeItem('Projeté', chartInterets, 0.30, proj, 0));
|
||||
return s;
|
||||
}
|
||||
if (inclureCapital) {
|
||||
const actual=sumA('capitalAmt'), proj=sumP('capitalProjAmt'), s=[];
|
||||
if(actual>0) s.push(makeItem('Reçu', chartCapital, 0.88, actual, 0));
|
||||
if(proj >0) s.push(makeItem('Projeté', chartCapital, 0.30, proj, 0));
|
||||
return s;
|
||||
}
|
||||
if (inclureCashback) {
|
||||
const v=sumA('cashbackAmt');
|
||||
return v>0?[makeItem('Reçu', chartCashback, 0.88, v, 0)]:[];
|
||||
}
|
||||
}
|
||||
// Multi-types — ordre : Intérêts → Capital → Cashback
|
||||
const s=[];
|
||||
if(inclureInterets){
|
||||
const a=sumA('interetsAmt'), p=sumP('interetsProjAmt');
|
||||
if(a+p>0) s.push(makeItem(netMode?'Intérêts nets':'Intérêts bruts', chartInterets, 0.88, a, p));
|
||||
}
|
||||
if(inclureCapital){
|
||||
const a=sumA('capitalAmt'), p=sumP('capitalProjAmt');
|
||||
if(a+p>0) s.push(makeItem('Capital', chartCapital, 0.88, a, p));
|
||||
}
|
||||
if(inclureCashback){
|
||||
const a=sumA('cashbackAmt');
|
||||
if(a>0) s.push(makeItem('Cashback', chartCashback, 0.88, a, 0));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Mode mensuel
|
||||
const src=selectedMonth!==null?months[selectedMonth]:null;
|
||||
const sumA=key=>showActual ?(src?(src[key]||0):months.reduce((s,m)=>s+(m[key]||0),0)):0;
|
||||
const sumP=key=>showProjected ?(src?(src[key]||0):months.reduce((s,m)=>s+(m[key]||0),0)):0;
|
||||
|
||||
if (activeCount===1) {
|
||||
if (inclureInterets) {
|
||||
const actual=sumA('interetsAmt'), proj=sumP('interetsProjAmt'), s=[];
|
||||
if(actual>0) s.push(makeItem('Reçu', chartInterets, 0.88, actual, 0));
|
||||
if(proj >0) s.push(makeItem('Projeté', chartInterets, 0.30, proj, 0));
|
||||
return s;
|
||||
}
|
||||
if (inclureCapital) {
|
||||
const actual=sumA('capitalAmt'), proj=sumP('capitalProjAmt'), s=[];
|
||||
if(actual>0) s.push(makeItem('Reçu', chartCapital, 0.88, actual, 0));
|
||||
if(proj >0) s.push(makeItem('Projeté', chartCapital, 0.30, proj, 0));
|
||||
return s;
|
||||
}
|
||||
if (inclureCashback) {
|
||||
const v=sumA('cashbackAmt');
|
||||
return v>0?[makeItem('Reçu', chartCashback, 0.88, v, 0)]:[];
|
||||
}
|
||||
}
|
||||
// Multi-types — ordre : Intérêts → Capital → Cashback
|
||||
const s=[];
|
||||
if(inclureInterets){
|
||||
const a=sumA('interetsAmt'), p=sumP('interetsProjAmt');
|
||||
if(a+p>0) s.push(makeItem(netMode?'Intérêts nets':'Intérêts bruts', chartInterets, 0.88, a, p));
|
||||
}
|
||||
if(inclureCapital){
|
||||
const a=sumA('capitalAmt'), p=sumP('capitalProjAmt');
|
||||
if(a+p>0) s.push(makeItem('Capital', chartCapital, 0.88, a, p));
|
||||
}
|
||||
if(inclureCashback){
|
||||
const a=sumA('cashbackAmt');
|
||||
if(a>0) s.push(makeItem('Cashback', chartCashback, 0.88, a, 0));
|
||||
}
|
||||
return s;
|
||||
},[months,selectedMonth,inclureCapital,inclureCashback,inclureInterets,
|
||||
showActual,showProjected,chartCapital,chartCashback,chartInterets,
|
||||
modeGlobal,years,selectedYear,activeCount]);
|
||||
|
||||
const total=donutData.reduce((s,d)=>s+d.value,0);
|
||||
const GAP = donutData.length <= 1 ? 0 : 4;
|
||||
|
||||
const arcs = useMemo(() => {
|
||||
const out=[]; let cur=0;
|
||||
donutData.forEach(d => {
|
||||
const sweep = total > 0 ? (d.value / total) * 360 : 0;
|
||||
const s = cur + GAP/2, e = cur + sweep - GAP/2;
|
||||
if (e - s > 0.1) out.push({...d, startDeg:s, endDeg:e});
|
||||
cur += sweep;
|
||||
});
|
||||
return out;
|
||||
},[donutData,total,GAP]);
|
||||
|
||||
/* ── Clic sur un quartier ou une ligne de légende ───────────── */
|
||||
const handleArcClick = (item) => {
|
||||
prevStateRef.current = {
|
||||
inclureInterets, inclureCapital, inclureCashback,
|
||||
showActual, showProjected,
|
||||
};
|
||||
setHasPrev(true);
|
||||
|
||||
if (activeCount > 1) {
|
||||
setInclureCapital(item.label === 'Capital');
|
||||
setInclureCashback(item.label === 'Cashback');
|
||||
setInclureInterets(item.label === 'Intérêts nets' || item.label === 'Intérêts bruts');
|
||||
} else {
|
||||
if (item.label === 'Reçu') selectActualOnly();
|
||||
if (item.label === 'Projeté') selectProjectedOnly();
|
||||
}
|
||||
};
|
||||
|
||||
/* ── Retour arrière via clic centre ──────────────────────────── */
|
||||
const handleCenterClick = () => {
|
||||
if (!hasPrev || !prevStateRef.current) return;
|
||||
const ps = prevStateRef.current;
|
||||
setInclureCapital(ps.inclureCapital);
|
||||
setInclureCashback(ps.inclureCashback);
|
||||
setInclureInterets(ps.inclureInterets);
|
||||
setActualProjected(ps.showActual, ps.showProjected);
|
||||
prevStateRef.current = null;
|
||||
setHasPrev(false);
|
||||
};
|
||||
|
||||
/* ── Tooltip positionné sur le point extérieur de l'arc ────── */
|
||||
const tooltipArc = hoveredArcIdx !== null ? arcs[hoveredArcIdx] : null;
|
||||
const tooltipPos = tooltipArc ? (() => {
|
||||
const midAngle = (tooltipArc.startDeg + tooltipArc.endDeg) / 2;
|
||||
const tipPt = polar(CX, CY, OUTER_R + 12, midAngle);
|
||||
return {
|
||||
pctX: (tipPt.x / 240) * 100,
|
||||
pctY: (tipPt.y / 240) * 100,
|
||||
onRight: tipPt.x >= CX,
|
||||
onBottom: tipPt.y >= CY,
|
||||
};
|
||||
})() : null;
|
||||
|
||||
/* ── Sous-détail reçu/projeté pour la légende ───────────────── */
|
||||
const getLegendDetail = (d) => {
|
||||
if (activeCount <= 1) return null;
|
||||
if (d.actualAmt > 0 && d.projectedAmt > 0) {
|
||||
if (d.label !== 'Intérêts nets' && d.label !== 'Intérêts bruts' && d.label !== 'Capital') return null;
|
||||
return `dont ${fmtTotal(d.actualAmt)} reçus · ${fmtTotal(d.projectedAmt)} projetés`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/* ── Labels / sélection ──────────────────────────────────────── */
|
||||
const centerLabel = modeGlobal
|
||||
? (selectedYear !== null ? String(selectedYear) : 'Total')
|
||||
: (selectedMonth !== null ? months[selectedMonth].label : 'Total');
|
||||
|
||||
const headerSub = modeGlobal
|
||||
? (selectedYear !== null ? String(selectedYear) : 'Toutes les années')
|
||||
: (selectedMonth !== null ? `${months[selectedMonth].labelLong} ${annee}` : String(annee));
|
||||
|
||||
const hasSelection = modeGlobal ? selectedYear !== null : selectedMonth !== null;
|
||||
|
||||
const clearSelection = () => {
|
||||
if (modeGlobal) setSelectedYear(null);
|
||||
else setSelectedMonth(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="solde-chart-wrap" style={{
|
||||
padding:'20px 20px 16px',
|
||||
height:'100%', boxSizing:'border-box',
|
||||
display:'flex', flexDirection:'column',
|
||||
}}>
|
||||
|
||||
{/* ── SVG donut ── */}
|
||||
<div style={{flex:1,display:'flex',alignItems:'center',justifyContent:'center',minHeight:0,padding:'4px 0'}}>
|
||||
<div style={{position:'relative',width:'100%',maxWidth:260}}
|
||||
onMouseLeave={()=>{ setHoveredArcIdx(null); setCenterHovered(false); }}
|
||||
>
|
||||
<svg viewBox="0 0 240 240" style={{width:'100%',height:'auto',display:'block'}}>
|
||||
{/* Anneau de fond */}
|
||||
<circle cx={CX} cy={CY} r={(OUTER_R+INNER_R)/2} fill="none"
|
||||
stroke="var(--border)" strokeWidth={OUTER_R-INNER_R} opacity={0.18}/>
|
||||
|
||||
{/* Segments */}
|
||||
{total>0 && arcs.map((arc,i)=>(
|
||||
<path key={i}
|
||||
d={roundedArcPath(CX,CY,OUTER_R,INNER_R,arc.startDeg,arc.endDeg)}
|
||||
fill={arc.color}
|
||||
fillRule={arc.endDeg-arc.startDeg>=359.9?'evenodd':undefined}
|
||||
opacity={hoveredArcIdx===i
|
||||
? Math.min(1,(arc.opacity??0.88)+0.12)
|
||||
: (arc.opacity??0.88)}
|
||||
style={{cursor:'pointer',transition:'opacity .12s'}}
|
||||
onMouseEnter={()=>setHoveredArcIdx(i)}
|
||||
onClick={()=>handleArcClick(arc)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Zone cliquable centre */}
|
||||
<circle cx={CX} cy={CY} r={INNER_R - 2}
|
||||
fill={centerHovered && hasPrev ? 'var(--primary)' : 'transparent'}
|
||||
fillOpacity={centerHovered && hasPrev ? 0.07 : 0}
|
||||
style={{ cursor: hasPrev ? 'pointer' : 'default', transition:'fill-opacity .15s' }}
|
||||
onMouseEnter={()=>setCenterHovered(true)}
|
||||
onMouseLeave={()=>setCenterHovered(false)}
|
||||
onClick={handleCenterClick}
|
||||
/>
|
||||
|
||||
{/* Indicateur retour arrière */}
|
||||
{hasPrev && (
|
||||
<text x={CX} y={CY-22} textAnchor="middle" fontSize="13"
|
||||
fill="var(--primary)" fontFamily="system-ui,sans-serif"
|
||||
opacity={centerHovered ? 1 : 0.55}
|
||||
style={{transition:'opacity .15s', pointerEvents:'none'}}>
|
||||
↩
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Texte central */}
|
||||
<text x={CX} y={hasPrev ? CY-4 : CY-9}
|
||||
textAnchor="middle" fontSize="12"
|
||||
fill={hasPrev && centerHovered ? 'var(--primary)' : 'var(--text-muted)'}
|
||||
fontFamily="system-ui,sans-serif" fontWeight="400"
|
||||
style={{transition:'fill .15s', pointerEvents:'none'}}>
|
||||
{centerLabel}
|
||||
</text>
|
||||
<text x={CX} y={hasPrev ? CY+14 : CY+13}
|
||||
textAnchor="middle" fontSize="18"
|
||||
fill={hasPrev && centerHovered ? 'var(--primary)' : 'var(--text)'}
|
||||
fontWeight="700" fontFamily="system-ui,sans-serif"
|
||||
style={{transition:'fill .15s', pointerEvents:'none'}}>
|
||||
{fmtCenter(total)}
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
{/* ── Tooltip arc ── */}
|
||||
{tooltipArc && tooltipPos && (
|
||||
<div style={{
|
||||
position:'absolute',
|
||||
...(tooltipPos.onRight
|
||||
? { left:`${tooltipPos.pctX}%`, transform:'translateX(8px)' }
|
||||
: { right:`${100-tooltipPos.pctX}%`, transform:'translateX(-8px)' }),
|
||||
...(tooltipPos.onBottom
|
||||
? { top:`${tooltipPos.pctY}%` }
|
||||
: { bottom:`${100-tooltipPos.pctY}%` }),
|
||||
pointerEvents:'none', zIndex:30,
|
||||
}}>
|
||||
<div className="sg-tooltip">
|
||||
<span className="sg-tooltip-date" style={{display:'flex',alignItems:'center',gap:6}}>
|
||||
<span style={{display:'inline-block',width:8,height:8,borderRadius:2,
|
||||
background:tooltipArc.color,opacity:tooltipArc.opacity??0.88,flexShrink:0}}/>
|
||||
{tooltipArc.label}
|
||||
</span>
|
||||
<span style={{fontSize:'var(--fs-sm)',color:'var(--text)',fontWeight:700}}>
|
||||
{fmtTotal(tooltipArc.value)}
|
||||
</span>
|
||||
<span style={{fontSize:'var(--fs-xs)',color:'var(--text-muted)'}}>
|
||||
{total>0 ? ((tooltipArc.value/total)*100).toFixed(1)+' %' : '—'}
|
||||
</span>
|
||||
{(activeCount > 1 || tooltipArc.label === 'Reçu' || tooltipArc.label === 'Projeté') && (
|
||||
<span style={{
|
||||
fontSize:'var(--fs-xs)',color:'var(--text-muted)',opacity:0.6,
|
||||
marginTop:2,borderTop:'1px solid var(--border)',paddingTop:4,
|
||||
}}>
|
||||
Cliquer pour {activeCount > 1 ? 'isoler' : 'sélectionner'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Tooltip centre ── */}
|
||||
{centerHovered && hasPrev && (
|
||||
<div style={{
|
||||
position:'absolute', left:'50%', top:'50%',
|
||||
transform:'translate(-50%, calc(-100% - 10px))',
|
||||
pointerEvents:'none', zIndex:30,
|
||||
}}>
|
||||
<div className="sg-tooltip" style={{textAlign:'center'}}>
|
||||
<span style={{fontSize:'var(--fs-xs)',color:'var(--primary)',fontWeight:600}}>
|
||||
↩ Restaurer le filtrage précédent
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Légende ── */}
|
||||
<div style={{marginTop:4}}>
|
||||
<div style={{
|
||||
display:'flex', alignItems:'center', justifyContent:'space-between',
|
||||
marginBottom:10, paddingBottom:10,
|
||||
borderBottom:'1px solid var(--border)',
|
||||
}}>
|
||||
<span style={{fontSize:'var(--fs-sm)',fontWeight:600,color:'var(--text)'}}>Répartition</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize:'var(--fs-xs)',
|
||||
color:hasSelection?'var(--primary)':'var(--text-muted)',
|
||||
cursor:hasSelection?'pointer':'default',
|
||||
display:'inline-flex', alignItems:'center', gap:4,
|
||||
}}
|
||||
onClick={hasSelection?()=>clearSelection():undefined}
|
||||
title={hasSelection?'Revenir à la vue globale':undefined}
|
||||
>
|
||||
{headerSub}
|
||||
{hasSelection&&<span style={{fontSize:11,opacity:0.7}}>×</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{donutData.length===0 && (
|
||||
<div style={{fontSize:'var(--fs-xs)',color:'var(--text-muted)',textAlign:'center',padding:'10px 0'}}>
|
||||
Aucun type sélectionné
|
||||
</div>
|
||||
)}
|
||||
|
||||
{donutData.map((d,i)=>{
|
||||
const detail = getLegendDetail(d);
|
||||
const isClickable = activeCount > 1 || d.label === 'Reçu' || d.label === 'Projeté';
|
||||
const isHov = hoveredLegIdx === i;
|
||||
return (
|
||||
<div key={i}
|
||||
onClick={isClickable ? ()=>handleArcClick(d) : undefined}
|
||||
onMouseEnter={isClickable ? ()=>setHoveredLegIdx(i) : undefined}
|
||||
onMouseLeave={isClickable ? ()=>setHoveredLegIdx(null) : undefined}
|
||||
style={{
|
||||
display:'flex', alignItems:'center', gap:8,
|
||||
padding:'7px 6px',
|
||||
marginLeft:-6, marginRight:-6,
|
||||
borderTop: i>0 ? '1px solid var(--border)' : 'none',
|
||||
cursor: isClickable ? 'pointer' : 'default',
|
||||
borderRadius:6,
|
||||
background: isHov ? 'var(--surface-2)' : 'transparent',
|
||||
transition:'background .12s',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
width:10,height:10,borderRadius:2,flexShrink:0,
|
||||
background:d.color,opacity:d.opacity??0.88,
|
||||
}}/>
|
||||
<div style={{flex:1,minWidth:0,display:'flex',alignItems:'baseline',gap:6,flexWrap:'wrap'}}>
|
||||
<span style={{fontSize:'var(--fs-xs)',color:'var(--text-muted)',fontWeight:400,whiteSpace:'nowrap'}}>
|
||||
{d.label}
|
||||
</span>
|
||||
<span style={{fontSize:'var(--fs-sm)',color:'var(--text)',fontWeight:700,whiteSpace:'nowrap'}}>
|
||||
{fmtTotal(d.value)}
|
||||
</span>
|
||||
{detail && (
|
||||
<span style={{fontSize:11,color:'var(--text-muted)',opacity:0.8,lineHeight:1.3}}>
|
||||
{detail}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span style={{fontSize:'var(--fs-xs)',color:'var(--text)',fontWeight:500,flexShrink:0,minWidth:38,textAlign:'right'}}>
|
||||
{total>0?((d.value/total)*100).toFixed(1)+' %':'—'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useInteretsChart } from '../context/InteretsChartContext.jsx';
|
||||
|
||||
const ICONS_BASE = '/api/icons-files/';
|
||||
|
||||
const GRID = 'rgba(255,255,255,0.055)';
|
||||
const LABEL = '#4a5568';
|
||||
const MOIS = ['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc'];
|
||||
|
||||
function fmtShort(v) {
|
||||
if (!v || v === 0) return '';
|
||||
const abs = Math.abs(v);
|
||||
if (abs >= 1000) return (v/1000).toLocaleString('fr-FR',{maximumFractionDigits:1})+'k €';
|
||||
return v.toLocaleString('fr-FR',{minimumFractionDigits:1,maximumFractionDigits:1})+' €';
|
||||
}
|
||||
function fmtTotal(v) {
|
||||
return v.toLocaleString('fr-FR',{minimumFractionDigits:2,maximumFractionDigits:2})+' €';
|
||||
}
|
||||
function hexToRgba(hex, a) {
|
||||
if (!hex||hex.length<7) return `rgba(79,168,232,${a})`;
|
||||
const r=parseInt(hex.slice(1,3),16), g=parseInt(hex.slice(3,5),16), b=parseInt(hex.slice(5,7),16);
|
||||
return `rgba(${r},${g},${b},${a})`;
|
||||
}
|
||||
|
||||
export default function InteretsMensuelsChart() {
|
||||
const {
|
||||
annee, setAnnee, availableYears,
|
||||
inclureInterets, setInclureInterets,
|
||||
inclureCapital, setInclureCapital,
|
||||
inclureCashback, setInclureCashback,
|
||||
selectedMonth, setSelectedMonth,
|
||||
showActual, toggleActual,
|
||||
showProjected, toggleProjected,
|
||||
months, annualTotal,
|
||||
// Mode global (TOUT)
|
||||
modeGlobal, toggleModeGlobal,
|
||||
selectedYear, setSelectedYear,
|
||||
years, globalTotal,
|
||||
netMode,
|
||||
chartInterets, chartCapital, chartCashback,
|
||||
} = useInteretsChart();
|
||||
|
||||
// État local uniquement
|
||||
const [hovered, setHovered] = useState(null);
|
||||
const [windowStart, setWindowStart] = useState(0);
|
||||
const [libIcons, setLibIcons] = useState({});
|
||||
const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.innerWidth < 768);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/icons').then(rows => {
|
||||
const m = {};
|
||||
rows.forEach(r => { m[r.name] = r.filename; });
|
||||
setLibIcons(m);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setIsMobile(window.innerWidth < 768);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
// Réinitialiser le hovered au changement de mode
|
||||
useEffect(() => { setHovered(null); }, [modeGlobal]);
|
||||
|
||||
const visibleYears = availableYears.length ? availableYears.slice(windowStart, windowStart+3) : [annee];
|
||||
const canPrev = windowStart > 0;
|
||||
const canNext = windowStart + 3 < availableYears.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (!availableYears.length || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
const idx = availableYears.indexOf(annee);
|
||||
const safe = idx >= 0 ? idx : availableYears.length - 1;
|
||||
setWindowStart(Math.max(0, Math.min(availableYears.length - 3, safe - 1)));
|
||||
}, [availableYears]);
|
||||
|
||||
/* ── Données affichées selon le mode ── */
|
||||
const items = modeGlobal ? years : months;
|
||||
const barCount = items.length;
|
||||
|
||||
/* ── SVG layout ── */
|
||||
const W = 900, H = 280;
|
||||
const PAD = { top: 52, right: 20, bottom: 34, left: 70 };
|
||||
const plotW = W - PAD.left - PAD.right;
|
||||
const plotH = H - PAD.top - PAD.bottom;
|
||||
const gap = modeGlobal ? 10 : 8;
|
||||
const barW = barCount > 0 ? Math.floor((plotW - gap * (barCount - 1)) / barCount) : 60;
|
||||
const barBotY = PAD.top + plotH;
|
||||
|
||||
const filteredTotal = item =>
|
||||
(showActual ? item.capitalAmt + item.cashbackAmt + item.interetsAmt : 0) +
|
||||
(showProjected ? item.capitalProjAmt + item.interetsProjAmt : 0);
|
||||
|
||||
const filteredSum = items.reduce((s, item) => s + filteredTotal(item), 0);
|
||||
const rawMax = Math.max(...items.map(item => filteredTotal(item)), 0.01);
|
||||
const niceStep = (() => {
|
||||
const raw = rawMax/4, mag = Math.pow(10,Math.floor(Math.log10(raw))), n = raw/mag;
|
||||
return (n<1.5?1:n<3.5?2:n<7.5?5:10)*mag;
|
||||
})();
|
||||
const niceMax = Math.ceil(rawMax/niceStep)*niceStep || niceStep;
|
||||
const yScale = v => PAD.top + plotH - (v/niceMax)*plotH;
|
||||
const barX = i => PAD.left + i*(barW+gap);
|
||||
const yTicks = Array.from({length: Math.round(niceMax/niceStep)+1}, (_,i) => ({v:i*niceStep, y:yScale(i*niceStep)}));
|
||||
|
||||
/* ── Tooltip ── */
|
||||
const tooltipItem = hovered !== null ? items[hovered] : null;
|
||||
const tooltipBCX = hovered !== null ? barX(hovered) + barW/2 : 0;
|
||||
const tooltipBTY = hovered !== null ? yScale(filteredTotal(items[hovered])) : 0;
|
||||
const anchorRight = tooltipBCX / W > 0.65;
|
||||
|
||||
const buildSegments = (item) => {
|
||||
const actual = [];
|
||||
if (showActual) {
|
||||
if (inclureCapital && item.capitalAmt > 0) actual.push({color:chartCapital, v:item.capitalAmt});
|
||||
if (inclureCashback && item.cashbackAmt > 0) actual.push({color:chartCashback, v:item.cashbackAmt});
|
||||
if (inclureInterets && item.interetsAmt > 0) actual.push({color:chartInterets, v:item.interetsAmt});
|
||||
}
|
||||
const projected = [];
|
||||
if (showProjected) {
|
||||
if (inclureCapital && item.capitalProjAmt > 0) projected.push({color:chartCapital, v:item.capitalProjAmt});
|
||||
if (inclureInterets && item.interetsProjAmt > 0) projected.push({color:chartInterets, v:item.interetsProjAmt});
|
||||
}
|
||||
return { actual, projected };
|
||||
};
|
||||
|
||||
const activeTypes = [
|
||||
inclureCapital && {key:'capital', color:chartCapital, label:'Capital'},
|
||||
inclureCashback && {key:'cashback', color:chartCashback, label:'Cashback'},
|
||||
inclureInterets && {key:'interets', color:chartInterets, label:netMode?'Intérêts nets':'Intérêts bruts'},
|
||||
].filter(Boolean);
|
||||
|
||||
const activeTypeCount = [inclureInterets, inclureCapital, inclureCashback].filter(Boolean).length;
|
||||
|
||||
const AppIcon = ({ name, size = 28, active = false }) => {
|
||||
const filename = libIcons[name];
|
||||
if (filename) return (
|
||||
<img src={ICONS_BASE + filename} className="app-lib-icon" width={size} height={size}
|
||||
aria-hidden="true"
|
||||
style={{ opacity: active ? 1 : 0.35, display: 'block', transition: 'opacity .15s' }} />
|
||||
);
|
||||
return <span style={{ width: size, height: size, display: 'block',
|
||||
borderRadius: 4, background: 'var(--text-muted)',
|
||||
opacity: active ? 0.55 : 0.2, transition: 'opacity .15s' }} />;
|
||||
};
|
||||
|
||||
/* ── Label en-tête ── */
|
||||
|
||||
|
||||
return (
|
||||
<div className="solde-chart-wrap" style={{padding:'24px 24px 16px', height:'100%', boxSizing:'border-box'}}>
|
||||
|
||||
{/* ── En-tête ── */}
|
||||
<div className="solde-chart-header">
|
||||
<div className="solde-chart-info">
|
||||
<div style={{display:'flex',alignItems:'center',gap:5,flexWrap:'wrap',marginBottom:2}}>
|
||||
{inclureInterets && (
|
||||
<span style={{display:'inline-flex',alignItems:'center',gap:4,background:hexToRgba(chartInterets,0.12),borderRadius:5,padding:'3px 8px'}}>
|
||||
<span style={{width:7,height:7,borderRadius:2,background:chartInterets,flexShrink:0}}/>
|
||||
<span style={{fontSize:13,color:chartInterets,fontWeight:600}}>{netMode?'Intérêts nets':'Intérêts bruts'}</span>
|
||||
</span>
|
||||
)}
|
||||
{inclureCapital && (
|
||||
<span style={{display:'inline-flex',alignItems:'center',gap:4,background:hexToRgba(chartCapital,0.12),borderRadius:5,padding:'3px 8px'}}>
|
||||
<span style={{width:7,height:7,borderRadius:2,background:chartCapital,flexShrink:0}}/>
|
||||
<span style={{fontSize:13,color:chartCapital,fontWeight:600}}>Capital</span>
|
||||
</span>
|
||||
)}
|
||||
{inclureCashback && (
|
||||
<span style={{display:'inline-flex',alignItems:'center',gap:4,background:hexToRgba(chartCashback,0.12),borderRadius:5,padding:'3px 8px'}}>
|
||||
<span style={{width:7,height:7,borderRadius:2,background:chartCashback,flexShrink:0}}/>
|
||||
<span style={{fontSize:13,color:chartCashback,fontWeight:600}}>Cashback</span>
|
||||
</span>
|
||||
)}
|
||||
{!inclureInterets && !inclureCapital && !inclureCashback && (
|
||||
<span style={{fontSize:13,color:'var(--text-muted)'}}>—</span>
|
||||
)}
|
||||
<span style={{fontSize:13,color:'var(--text-muted)'}}>· {modeGlobal?'Toutes les années':annee}</span>
|
||||
</div>
|
||||
<div className="solde-chart-value">{fmtTotal(filteredSum)}</div>
|
||||
</div>
|
||||
|
||||
{isMobile ? (
|
||||
/* ── Mobile : bouton Filtres ── */
|
||||
<button
|
||||
onClick={()=>setSheetOpen(true)}
|
||||
style={{
|
||||
display:'flex', alignItems:'center', gap:7,
|
||||
background:'var(--surface-2)', border:'1px solid var(--border)',
|
||||
borderRadius:20, padding:'6px 14px', cursor:'pointer',
|
||||
fontSize:'var(--fs-sm)', fontWeight:600, color:'var(--text)',
|
||||
}}>
|
||||
Filtres
|
||||
{activeTypeCount > 0 && (
|
||||
<span style={{
|
||||
background:'var(--primary)', color:'#fff',
|
||||
borderRadius:10, padding:'1px 7px',
|
||||
fontSize:11, fontWeight:700,
|
||||
}}>{activeTypeCount}</span>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
/* ── Desktop : controls existants ── */
|
||||
<div className="solde-chart-controls">
|
||||
<button
|
||||
title={inclureInterets?'Intérêts inclus — cliquer pour exclure':'Cliquer pour inclure les intérêts'}
|
||||
onClick={()=>setInclureInterets(v=>!v)}
|
||||
style={{background:inclureInterets?hexToRgba(chartInterets,0.13):'none', border:'1px solid '+(inclureInterets?chartInterets:'transparent'), borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center', transition:'background .15s,border-color .15s', marginRight:2}}>
|
||||
<AppIcon name="interets" active={inclureInterets} />
|
||||
</button>
|
||||
<button
|
||||
title={inclureCapital?'Capital inclus — cliquer pour exclure':'Cliquer pour inclure le capital'}
|
||||
onClick={()=>setInclureCapital(v=>!v)}
|
||||
style={{background:inclureCapital?hexToRgba(chartCapital,0.13):'none', border:'1px solid '+(inclureCapital?chartCapital:'transparent'), borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center', transition:'background .15s,border-color .15s', marginRight:2}}>
|
||||
<AppIcon name="capital" active={inclureCapital} />
|
||||
</button>
|
||||
<button
|
||||
title={inclureCashback?'Cashback inclus — cliquer pour exclure':'Cliquer pour inclure le cashback'}
|
||||
onClick={()=>setInclureCashback(v=>!v)}
|
||||
style={{background:inclureCashback?hexToRgba(chartCashback,0.13):'none', border:'1px solid '+(inclureCashback?chartCashback:'transparent'), borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center', transition:'background .15s,border-color .15s', marginRight:2}}>
|
||||
<AppIcon name="cashback" active={inclureCashback} />
|
||||
</button>
|
||||
<div className="solde-chart-ranges">
|
||||
{!modeGlobal && <>
|
||||
<button className="solde-range-btn" onClick={()=>setWindowStart(w=>Math.max(0,w-1))} disabled={!canPrev} style={{opacity:canPrev?1:0.3}}>‹</button>
|
||||
{visibleYears.map(y=>(
|
||||
<button key={y} className={`solde-range-btn${annee===y?' active':''}`}
|
||||
onClick={()=>{ setAnnee(y); }}>
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
<button className="solde-range-btn" onClick={()=>setWindowStart(w=>Math.min(Math.max(0,availableYears.length-3),w+1))} disabled={!canNext} style={{opacity:canNext?1:0.3}}>›</button>
|
||||
</>}
|
||||
<button className={`solde-range-btn${modeGlobal?' active':''}`} onClick={()=>toggleModeGlobal()}>
|
||||
TOUT
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── SVG bar chart ── */}
|
||||
<div style={{position:'relative', userSelect:'none'}}>
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
style={{width:'100%', height:'auto', display:'block'}}
|
||||
onMouseLeave={()=>setHovered(null)}
|
||||
>
|
||||
{yTicks.map(({v,y},i)=>(
|
||||
<g key={i}>
|
||||
<line x1={PAD.left} y1={y} x2={W-PAD.right} y2={y} stroke={GRID} strokeWidth="1" strokeDasharray={i===0?'':'3 5'}/>
|
||||
<text x={PAD.left-10} y={y+4} textAnchor="end" fill={LABEL} fontSize="11" fontFamily="system-ui,sans-serif">{v===0?'':fmtShort(v)}</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{items.map((item,i)=>{
|
||||
const x=barX(i), isHov=hovered===i;
|
||||
// Sélection : mois en mode mensuel, année en mode global
|
||||
const isSel = modeGlobal ? (selectedYear===item.y) : (selectedMonth===i);
|
||||
const dimmed = modeGlobal
|
||||
? (selectedYear!==null && !isSel)
|
||||
: (selectedMonth!==null && !isSel);
|
||||
const {actual:aSegs,projected:pSegs}=buildSegments(item);
|
||||
let cumH=0;
|
||||
const aRects=aSegs.map(s=>{const h=(s.v/niceMax)*plotH; const r={color:s.color,x,y:barBotY-cumH-h,w:barW,h,isP:false}; cumH+=h; return r;});
|
||||
const pRects=pSegs.map(s=>{const h=(s.v/niceMax)*plotH; const r={color:s.color,x,y:barBotY-cumH-h,w:barW,h,isP:true}; cumH+=h; return r;});
|
||||
const all=[...aRects,...pRects];
|
||||
const totalH=(filteredTotal(item)/niceMax)*plotH;
|
||||
const topColor=all.length>0?all[all.length-1].color:LABEL;
|
||||
const isCurrentMark = modeGlobal ? item.isCurrent : item.isCurrentMonth;
|
||||
return (
|
||||
<g key={i}
|
||||
onMouseEnter={()=>setHovered(i)}
|
||||
onClick={()=>{
|
||||
if (modeGlobal) {
|
||||
setSelectedYear(prev => prev===item.y ? null : item.y);
|
||||
} else {
|
||||
setSelectedMonth(prev => prev===i ? null : i);
|
||||
}
|
||||
}}
|
||||
style={{cursor:'pointer', opacity:dimmed?0.35:1, transition:'opacity .15s'}}
|
||||
>
|
||||
{all.map((r,ri)=>(
|
||||
<rect key={ri} x={r.x} y={r.y} width={r.w} height={r.h}
|
||||
fill={r.color}
|
||||
fillOpacity={r.isP?(isHov||isSel?0.48:0.28):(isHov||isSel?1.0:0.82)}
|
||||
rx={ri===all.length-1?2:0} ry={ri===all.length-1?2:0}
|
||||
/>
|
||||
))}
|
||||
{filteredTotal(item)>0&&(
|
||||
<text x={x+barW/2} y={barBotY-totalH-5} textAnchor="middle"
|
||||
fill={isHov||isSel?topColor:LABEL} fontSize="9" fontFamily="system-ui,sans-serif"
|
||||
fontWeight={isHov||isSel?'700':undefined}>
|
||||
{fmtShort(filteredTotal(item))}
|
||||
</text>
|
||||
)}
|
||||
<text x={x+barW/2} y={H-10} textAnchor="middle"
|
||||
fill={isCurrentMark||isSel?topColor:LABEL}
|
||||
fontSize="10" fontFamily="system-ui,sans-serif"
|
||||
fontWeight={isCurrentMark||isSel?'700':undefined}>
|
||||
{item.label}
|
||||
</text>
|
||||
{isSel&&<rect x={x+barW/2-7} y={H-3} width={14} height={3} rx={1.5} fill={topColor} fillOpacity={0.9}/>}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Tooltip */}
|
||||
{tooltipItem&&tooltipItem.total>0&&(
|
||||
<div style={{position:'absolute', ...(anchorRight?{right:`calc(${(1-tooltipBCX/W)*100}% + 8px)`}:{left:`calc(${(tooltipBCX/W)*100}% + 8px)`}), top:`calc(${(tooltipBTY/H)*100}% - 8px)`, transform:'translateY(-100%)', pointerEvents:'none', zIndex:20}}>
|
||||
<div className="sg-tooltip">
|
||||
<span className="sg-tooltip-date">
|
||||
{modeGlobal
|
||||
? `${tooltipItem.label}${tooltipItem.isCurrent?' · année en cours':''}`
|
||||
: `${MOIS[tooltipItem.m-1]} ${annee}${tooltipItem.isCurrentMonth?' · mois en cours':''}`
|
||||
}
|
||||
</span>
|
||||
{(()=>{
|
||||
const ROW=({label,value,color=null,indent=false,muted=false})=>(
|
||||
<span style={{fontSize:'var(--fs-xs)',color:'var(--text-muted)',display:'flex',justifyContent:'space-between',gap:16,alignItems:'center'}}>
|
||||
{color&&<span style={{display:'inline-block',width:8,height:8,borderRadius:2,background:color,flexShrink:0}}/>}
|
||||
<span style={{paddingLeft:(!color&&indent)?8:0,flex:1}}>{label}</span>
|
||||
<span style={{color:muted?undefined:'var(--text)',fontWeight:muted?undefined:600}}>{value}</span>
|
||||
</span>
|
||||
);
|
||||
const hasMix=tooltipItem.actual>0&&tooltipItem.projected>0;
|
||||
const multi=[inclureCapital,inclureCashback,inclureInterets].filter(Boolean).length>1;
|
||||
return(<>
|
||||
{tooltipItem.actual>0&&<>
|
||||
<ROW label="Reçu" value={fmtTotal(tooltipItem.actual)}/>
|
||||
{multi&&inclureCapital &&tooltipItem.capitalAmt >0&&<ROW label="Capital" value={fmtTotal(tooltipItem.capitalAmt)} color={chartCapital} indent muted/>}
|
||||
{multi&&inclureCashback&&tooltipItem.cashbackAmt>0&&<ROW label="Cashback" value={fmtTotal(tooltipItem.cashbackAmt)} color={chartCashback} indent muted/>}
|
||||
{multi&&inclureInterets&&tooltipItem.interetsAmt>0&&<ROW label={`Intérêts (${netMode?'nets':'bruts'})`} value={fmtTotal(tooltipItem.interetsAmt)} color={chartInterets} indent muted/>}
|
||||
{!multi&&inclureInterets&&<ROW label={`dont intérêts (${netMode?'nets':'bruts'})`} value={fmtTotal(tooltipItem.interetsAmt)} indent muted/>}
|
||||
{!multi&&inclureCapital &&<ROW label="dont capital" value={fmtTotal(tooltipItem.capitalAmt)} indent muted/>}
|
||||
{!multi&&inclureCashback&&<ROW label="dont cashback" value={fmtTotal(tooltipItem.cashbackAmt)} indent muted/>}
|
||||
</>}
|
||||
{tooltipItem.projected>0&&<>
|
||||
<ROW label="Projeté" value={fmtTotal(tooltipItem.projected)}/>
|
||||
{multi&&inclureCapital &&tooltipItem.capitalProjAmt >0&&<ROW label="Capital" value={fmtTotal(tooltipItem.capitalProjAmt)} color={chartCapital} indent muted/>}
|
||||
{multi&&inclureInterets&&tooltipItem.interetsProjAmt>0&&<ROW label={`Intérêts (${netMode?'nets est.':'bruts'})`} value={fmtTotal(tooltipItem.interetsProjAmt)} color={chartInterets} indent muted/>}
|
||||
{!multi&&inclureInterets&&<ROW label={`dont intérêts (${netMode?'nets est.':'bruts'})`} value={fmtTotal(tooltipItem.interetsProjAmt)} indent muted/>}
|
||||
{!multi&&inclureCapital&&tooltipItem.capitalProjAmt>0&&<ROW label="dont capital" value={fmtTotal(tooltipItem.capitalProjAmt)} indent muted/>}
|
||||
</>}
|
||||
{hasMix&&<span className="sg-tooltip-value" style={{borderTop:'1px solid var(--border)',paddingTop:4,marginTop:2,display:'flex',justifyContent:'space-between'}}><span>Total</span><span>{fmtTotal(tooltipItem.total)}</span></span>}
|
||||
{!hasMix&&<span className="sg-tooltip-value" style={{display:'flex',justifyContent:'space-between'}}><span>Total</span><span>{fmtTotal(tooltipItem.total)}</span></span>}
|
||||
</>);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Légende + sélecteur Reçu/Projeté ── */}
|
||||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginTop:8,gap:12,flexWrap:'wrap'}}>
|
||||
|
||||
{/* Sélecteur Reçu/Projeté — desktop uniquement (mobile → bottom sheet) */}
|
||||
{!isMobile && (
|
||||
<div style={{
|
||||
display:'inline-flex',
|
||||
background:'#f0f0f0',
|
||||
borderRadius:8,
|
||||
padding:3,
|
||||
gap:2,
|
||||
flexShrink:0,
|
||||
}}>
|
||||
{[
|
||||
{key:'actual', label:'Reçu', active:showActual, toggle:toggleActual},
|
||||
{key:'projected', label:'Projeté', active:showProjected, toggle:toggleProjected},
|
||||
].map(btn=>(
|
||||
<button key={btn.key} onClick={()=>btn.toggle()} style={{
|
||||
border:'none', cursor:'pointer', padding:'5px 14px', borderRadius:6,
|
||||
fontSize:'var(--fs-sm)',
|
||||
fontWeight: btn.active ? 600 : 400,
|
||||
background: btn.active ? '#ffffff' : 'transparent',
|
||||
color: btn.active ? '#1a1a2e' : '#9ca3af',
|
||||
boxShadow: btn.active ? '0 1px 3px rgba(0,0,0,0.13)' : 'none',
|
||||
transition: 'all .15s', lineHeight: 1.4,
|
||||
}}>{btn.label}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Légende couleurs */}
|
||||
<div style={{display:'flex',gap:12,alignItems:'center',flexWrap:'wrap',flex:1,justifyContent:'flex-end'}}>
|
||||
{activeTypes.map(t=>(
|
||||
<div key={t.key} style={{display:'flex',gap:8,alignItems:'center'}}>
|
||||
{showActual&&(
|
||||
<span style={{display:'flex',gap:4,alignItems:'center',fontSize:'var(--fs-xs)',color:'var(--text-muted)'}}>
|
||||
<span style={{width:10,height:10,borderRadius:2,background:t.color,opacity:0.82,flexShrink:0}}/>
|
||||
{t.label}{t.key==='interets'?' reçus':' reçu'}
|
||||
</span>
|
||||
)}
|
||||
{showProjected&&(
|
||||
<span style={{display:'flex',gap:4,alignItems:'center',fontSize:'var(--fs-xs)',color:'var(--text-muted)'}}>
|
||||
<span style={{width:10,height:10,borderRadius:2,background:t.color,opacity:0.28,flexShrink:0}}/>
|
||||
{t.label}{t.key==='interets'?' projetés':' projeté'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Bottom sheet mobile ── */}
|
||||
{isMobile && (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
<div
|
||||
onClick={()=>setSheetOpen(false)}
|
||||
style={{
|
||||
position:'fixed', inset:0, zIndex:200,
|
||||
background:'rgba(0,0,0,0.45)',
|
||||
opacity: sheetOpen ? 1 : 0,
|
||||
pointerEvents: sheetOpen ? 'auto' : 'none',
|
||||
transition:'opacity .25s',
|
||||
}}
|
||||
/>
|
||||
{/* Sheet */}
|
||||
<div style={{
|
||||
position:'fixed', bottom:0, left:0, right:0, zIndex:201,
|
||||
background:'var(--surface)',
|
||||
borderRadius:'20px 20px 0 0',
|
||||
borderTop:'1px solid var(--border)',
|
||||
padding:'12px 20px 32px',
|
||||
transform: sheetOpen ? 'translateY(0)' : 'translateY(100%)',
|
||||
transition:'transform .3s cubic-bezier(.32,.72,0,1)',
|
||||
maxHeight:'85vh', overflowY:'auto',
|
||||
}}>
|
||||
{/* Handle */}
|
||||
<div style={{width:36,height:4,background:'var(--border)',borderRadius:2,margin:'0 auto 20px'}}/>
|
||||
|
||||
{/* Types */}
|
||||
<div style={{fontSize:'var(--fs-xs)',color:'var(--text-muted)',fontWeight:600,textTransform:'uppercase',letterSpacing:'0.06em',marginBottom:12}}>
|
||||
Types
|
||||
</div>
|
||||
{[
|
||||
{key:'interets', label:netMode?'Intérêts nets':'Intérêts bruts', color:chartInterets, active:inclureInterets, set:setInclureInterets},
|
||||
{key:'capital', label:'Capital', color:chartCapital, active:inclureCapital, set:setInclureCapital},
|
||||
{key:'cashback', label:'Cashback', color:chartCashback, active:inclureCashback, set:setInclureCashback},
|
||||
].map((t,i,arr)=>(
|
||||
<div key={t.key} onClick={()=>t.set(v=>!v)} style={{
|
||||
display:'flex', alignItems:'center', justifyContent:'space-between',
|
||||
padding:'12px 0',
|
||||
borderBottom: i<arr.length-1 ? '1px solid var(--border)' : 'none',
|
||||
cursor:'pointer',
|
||||
}}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:10}}>
|
||||
<span style={{width:10,height:10,borderRadius:2,background:t.color,flexShrink:0}}/>
|
||||
<span style={{fontSize:'var(--fs-sm)',color:'var(--text)',fontWeight:500}}>{t.label}</span>
|
||||
</div>
|
||||
{/* Toggle */}
|
||||
<div style={{
|
||||
width:44, height:24, borderRadius:12, flexShrink:0,
|
||||
background: t.active ? t.color : 'var(--border)',
|
||||
position:'relative', transition:'background .2s',
|
||||
}}>
|
||||
<div style={{
|
||||
width:20, height:20, borderRadius:10, background:'#fff',
|
||||
position:'absolute', top:2,
|
||||
left: t.active ? 22 : 2,
|
||||
transition:'left .2s',
|
||||
boxShadow:'0 1px 3px rgba(0,0,0,0.2)',
|
||||
}}/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Période */}
|
||||
<div style={{fontSize:'var(--fs-xs)',color:'var(--text-muted)',fontWeight:600,textTransform:'uppercase',letterSpacing:'0.06em',margin:'20px 0 12px'}}>
|
||||
Période
|
||||
</div>
|
||||
<div style={{display:'flex',gap:8,flexWrap:'wrap',alignItems:'center'}}>
|
||||
{availableYears.map(y=>(
|
||||
<button key={y}
|
||||
onClick={()=>{ setAnnee(y); if(modeGlobal) toggleModeGlobal(); }}
|
||||
style={{
|
||||
padding:'7px 16px', borderRadius:8, fontSize:'var(--fs-sm)',
|
||||
fontWeight: !modeGlobal && annee===y ? 700 : 500,
|
||||
background: !modeGlobal && annee===y ? 'var(--primary)' : 'var(--surface-2)',
|
||||
color: !modeGlobal && annee===y ? '#fff' : 'var(--text)',
|
||||
border: !modeGlobal && annee===y ? '1px solid var(--primary)' : '1px solid var(--border)',
|
||||
cursor:'pointer',
|
||||
}}>
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={()=>{ if(!modeGlobal) toggleModeGlobal(); }}
|
||||
style={{
|
||||
padding:'7px 16px', borderRadius:8, fontSize:'var(--fs-sm)',
|
||||
fontWeight: modeGlobal ? 700 : 500,
|
||||
background: modeGlobal ? 'var(--primary)' : 'var(--surface-2)',
|
||||
color: modeGlobal ? '#fff' : 'var(--text)',
|
||||
border: modeGlobal ? '1px solid var(--primary)' : '1px solid var(--border)',
|
||||
cursor:'pointer',
|
||||
}}>
|
||||
Tout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Affichage */}
|
||||
<div style={{fontSize:'var(--fs-xs)',color:'var(--text-muted)',fontWeight:600,textTransform:'uppercase',letterSpacing:'0.06em',margin:'20px 0 12px'}}>
|
||||
Affichage
|
||||
</div>
|
||||
{[
|
||||
{key:'actual', label:'Reçu', active:showActual, toggle:toggleActual},
|
||||
{key:'projected', label:'Projeté', active:showProjected, toggle:toggleProjected},
|
||||
].map((btn,i,arr)=>(
|
||||
<div key={btn.key} onClick={()=>btn.toggle()} style={{
|
||||
display:'flex', alignItems:'center', justifyContent:'space-between',
|
||||
padding:'12px 0',
|
||||
borderBottom: i<arr.length-1 ? '1px solid var(--border)' : 'none',
|
||||
cursor:'pointer',
|
||||
}}>
|
||||
<span style={{fontSize:'var(--fs-sm)',color:'var(--text)',fontWeight:500}}>{btn.label}</span>
|
||||
<div style={{
|
||||
width:44, height:24, borderRadius:12, flexShrink:0,
|
||||
background: btn.active ? 'var(--primary)' : 'var(--border)',
|
||||
position:'relative', transition:'background .2s',
|
||||
}}>
|
||||
<div style={{
|
||||
width:20, height:20, borderRadius:10, background:'#fff',
|
||||
position:'absolute', top:2,
|
||||
left: btn.active ? 22 : 2,
|
||||
transition:'left .2s',
|
||||
boxShadow:'0 1px 3px rgba(0,0,0,0.2)',
|
||||
}}/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { useMemo, useState, useRef } from 'react';
|
||||
|
||||
/* ── Constantes ─────────────────────────────────────────────── */
|
||||
const BLUE = '#4fa8e8';
|
||||
const BG = '#070c15';
|
||||
const GRID = 'rgba(255,255,255,0.055)';
|
||||
const LABEL = '#4a5568';
|
||||
|
||||
const RANGES = ['1J', '7J', '1M', '3M', 'YTD', '1A', 'TOUT'];
|
||||
const MOIS_COURT = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.'];
|
||||
|
||||
const W = 900, H = 260;
|
||||
const PAD = { top: 16, right: 16, bottom: 32, left: 70 };
|
||||
const plotW = W - PAD.left - PAD.right;
|
||||
const plotH = H - PAD.top - PAD.bottom;
|
||||
|
||||
/* ── Helpers ────────────────────────────────────────────────── */
|
||||
function fmtK(v) {
|
||||
if (v === 0) return '0 €';
|
||||
const abs = Math.abs(v);
|
||||
if (abs >= 1000) return (v / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' k €';
|
||||
return v.toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' €';
|
||||
}
|
||||
|
||||
function fmtAxisDate(dateStr, range) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const mon = MOIS_COURT[d.getMonth()];
|
||||
const yr = d.getFullYear();
|
||||
if (range === '1J' || range === '7J') return `${day}/${String(d.getMonth()+1).padStart(2,'0')}`;
|
||||
if (range === '1M' || range === '3M') return `${day} ${mon}`;
|
||||
return `${day}/${String(d.getMonth()+1).padStart(2,'0')}/${String(yr).slice(2)}`;
|
||||
}
|
||||
|
||||
function fmtValueDisplay(v) {
|
||||
return v.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }) + ' €';
|
||||
}
|
||||
|
||||
/* ── Composant ──────────────────────────────────────────────── */
|
||||
export default function InvChart({ rows, remboursements, reinvestissements, platYear }) {
|
||||
const [range, setRange] = useState('TOUT');
|
||||
const [hover, setHover] = useState(null);
|
||||
const svgRef = useRef(null);
|
||||
const wrapRef = useRef(null);
|
||||
|
||||
/* ── Date de coupure : 31/12/{platYear} pour les années passées, aujourd'hui pour l'année en cours ou sans filtre ── */
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
const currentYear = String(new Date().getFullYear());
|
||||
const cutoffStr = platYear && platYear !== currentYear ? `${platYear}-12-31` : todayStr;
|
||||
|
||||
/* ── 1. Courbe capital en cours = investissements − capital remboursé ── */
|
||||
const allPoints = useMemo(() => {
|
||||
if (!rows?.length) return [];
|
||||
|
||||
// Tous les investissements du scope (le capRestant sera 0 pour les remboursés)
|
||||
const rowIds = new Set(rows.map(r => r.id));
|
||||
|
||||
// Variations de capital par date
|
||||
const deltas = {};
|
||||
|
||||
// +montant_investi à chaque date de souscription
|
||||
for (const r of rows) {
|
||||
const d = r.date_souscription?.slice(0, 10);
|
||||
if (!d || d > cutoffStr) continue;
|
||||
deltas[d] = (deltas[d] || 0) + (r.montant_investi ?? 0);
|
||||
}
|
||||
|
||||
// +réinvestissements à leur date propre (dans le scope, avant coupure)
|
||||
if (reinvestissements?.length) {
|
||||
for (const rv of reinvestissements) {
|
||||
if (!rowIds.has(rv.investissement_id)) continue;
|
||||
const d = rv.date_reinvestissement?.slice(0, 10);
|
||||
if (!d || !rv.montant || d > cutoffStr) continue;
|
||||
deltas[d] = (deltas[d] || 0) + rv.montant;
|
||||
}
|
||||
}
|
||||
|
||||
// -capital à chaque date de remboursement (investissements du scope, avant coupure)
|
||||
if (remboursements?.length) {
|
||||
for (const rb of remboursements) {
|
||||
if (!rowIds.has(rb.investissement_id)) continue;
|
||||
const d = rb.date_remb?.slice(0, 10);
|
||||
if (!d || !rb.capital || d > cutoffStr) continue;
|
||||
deltas[d] = (deltas[d] || 0) - rb.capital;
|
||||
}
|
||||
}
|
||||
|
||||
// Tri chronologique + cumul
|
||||
let cum = 0;
|
||||
return Object.keys(deltas).sort().map(date => {
|
||||
cum += deltas[date];
|
||||
return { date, value: Math.max(0, cum) };
|
||||
});
|
||||
}, [rows, remboursements, reinvestissements, cutoffStr]);
|
||||
|
||||
const currentValue = allPoints.length ? allPoints[allPoints.length - 1].value : 0;
|
||||
|
||||
/* ── 2. Filtrage par plage ── */
|
||||
const points = useMemo(() => {
|
||||
if (!allPoints.length) return [];
|
||||
if (range === 'TOUT') {
|
||||
const pts = [...allPoints];
|
||||
if (pts[pts.length - 1].date < cutoffStr) pts.push({ date: cutoffStr, value: pts[pts.length - 1].value });
|
||||
return pts;
|
||||
}
|
||||
const now = new Date(cutoffStr + 'T00:00:00');
|
||||
let fromDate = new Date(now);
|
||||
switch (range) {
|
||||
case '1J': fromDate.setDate(now.getDate() - 1); break;
|
||||
case '7J': fromDate.setDate(now.getDate() - 7); break;
|
||||
case '1M': fromDate.setMonth(now.getMonth() - 1); break;
|
||||
case '3M': fromDate.setMonth(now.getMonth() - 3); break;
|
||||
case 'YTD': fromDate = new Date(now.getFullYear(), 0, 1); break;
|
||||
case '1A': fromDate.setFullYear(now.getFullYear() - 1); break;
|
||||
}
|
||||
const fromStr = fromDate.toISOString().slice(0, 10);
|
||||
const before = allPoints.filter(p => p.date < fromStr);
|
||||
const startV = before.length ? before[before.length - 1].value : 0;
|
||||
const after = allPoints.filter(p => p.date >= fromStr);
|
||||
const pts = [{ date: fromStr, value: startV }, ...after];
|
||||
if (pts[pts.length - 1].date < cutoffStr) pts.push({ date: cutoffStr, value: pts[pts.length - 1].value });
|
||||
return pts;
|
||||
}, [allPoints, range, cutoffStr]);
|
||||
|
||||
/* ── 3. Échelles ── */
|
||||
const { xScale, yScale, yTicks, xTicks, minDate, maxDate, yZero } = useMemo(() => {
|
||||
if (points.length < 2) return {};
|
||||
const vals = points.map(p => p.value);
|
||||
const dataMin = Math.min(...vals);
|
||||
const dataMax = Math.max(...vals);
|
||||
const lo = Math.min(0, dataMin);
|
||||
const hi = Math.max(0, dataMax);
|
||||
const pad = (hi - lo) * 0.1 || 10;
|
||||
const scaleLo = lo - (lo < 0 ? pad : 0);
|
||||
const scaleHi = hi + pad;
|
||||
const valRange = scaleHi - scaleLo || 1;
|
||||
|
||||
const ts = points.map(p => new Date(p.date + 'T00:00:00').getTime());
|
||||
const minDt = ts[0];
|
||||
const maxDt = ts[ts.length - 1];
|
||||
const dtRange = maxDt - minDt || 1;
|
||||
|
||||
const xScale = d => PAD.left + ((new Date(d + 'T00:00:00').getTime() - minDt) / dtRange) * plotW;
|
||||
const yScale = v => PAD.top + plotH - ((v - scaleLo) / valRange) * plotH;
|
||||
|
||||
const step = (scaleHi - scaleLo) / 4;
|
||||
const yTicks = Array.from({ length: 5 }, (_, i) => ({ v: scaleLo + step * i, y: yScale(scaleLo + step * i) }));
|
||||
|
||||
const nX = Math.min(8, points.length);
|
||||
const xTicks = Array.from({ length: nX }, (_, i) => {
|
||||
const idx = Math.round((i / (nX - 1)) * (points.length - 1));
|
||||
return points[idx];
|
||||
});
|
||||
|
||||
return { xScale, yScale, yTicks, xTicks, minDate: minDt, maxDate: maxDt, yZero: yScale(0) };
|
||||
}, [points]);
|
||||
|
||||
/* ── 4. Chemins SVG ── */
|
||||
const { linePath, areaPath } = useMemo(() => {
|
||||
if (!xScale || points.length < 2) return { linePath: '', areaPath: '' };
|
||||
let line = `M ${xScale(points[0].date).toFixed(1)},${yScale(points[0].value).toFixed(1)}`;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
line += ` H ${xScale(points[i].date).toFixed(1)} V ${yScale(points[i].value).toFixed(1)}`;
|
||||
}
|
||||
const zeroY = yZero?.toFixed(1) ?? (PAD.top + plotH).toFixed(1);
|
||||
const area = `${line} V ${zeroY} H ${PAD.left} Z`;
|
||||
return { linePath: line, areaPath: area };
|
||||
}, [points, xScale, yScale, yZero]);
|
||||
|
||||
/* ── 5. Hover ── */
|
||||
const handleMouseMove = (e) => {
|
||||
if (!svgRef.current || !xScale || points.length < 2) return;
|
||||
const rect = svgRef.current.getBoundingClientRect();
|
||||
const svgX = ((e.clientX - rect.left) / rect.width) * W;
|
||||
const t = minDate + ((svgX - PAD.left) / plotW) * (maxDate - minDate);
|
||||
let nearest = points[0], minDiff = Infinity;
|
||||
for (const p of points) {
|
||||
const diff = Math.abs(new Date(p.date + 'T00:00:00').getTime() - t);
|
||||
if (diff < minDiff) { minDiff = diff; nearest = p; }
|
||||
}
|
||||
setHover({ x: xScale(nearest.date), y: yScale(nearest.value), value: nearest.value, date: nearest.date });
|
||||
};
|
||||
|
||||
/* ── Tooltip ── */
|
||||
const tooltipStyle = useMemo(() => {
|
||||
if (!hover) return null;
|
||||
const xPct = (hover.x / W) * 100;
|
||||
const yPct = (hover.y / H) * 100;
|
||||
const anchorRight = xPct > 65;
|
||||
return {
|
||||
position: 'absolute',
|
||||
top: `calc(${yPct}% - 64px)`,
|
||||
left: anchorRight ? 'auto' : `calc(${xPct}% + 12px)`,
|
||||
right: anchorRight ? `calc(${100 - xPct}% + 12px)` : 'auto',
|
||||
transform: 'none',
|
||||
pointerEvents: 'none',
|
||||
};
|
||||
}, [hover]);
|
||||
|
||||
if (!allPoints.length) return null;
|
||||
|
||||
const displayLabel = platYear ? `31/12/${platYear}` : "Aujourd'hui";
|
||||
const displayDate = hover
|
||||
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })
|
||||
: displayLabel;
|
||||
const displayValue = fmtValueDisplay(hover ? hover.value : currentValue);
|
||||
const tooltipDate = hover
|
||||
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="solde-chart-wrap" ref={wrapRef} style={{ position: 'relative' }}>
|
||||
|
||||
{/* ── En-tête ── */}
|
||||
<div className="solde-chart-header">
|
||||
<div className="solde-chart-info">
|
||||
<div className="solde-chart-date">Capital investi · {displayDate}</div>
|
||||
<div className="solde-chart-value">{displayValue}</div>
|
||||
</div>
|
||||
<div className="solde-chart-controls">
|
||||
<div className="solde-chart-ranges">
|
||||
{RANGES.map(r => (
|
||||
<button key={r}
|
||||
className={`solde-range-btn${range === r ? ' active' : ''}`}
|
||||
onClick={() => { setRange(r); setHover(null); }}>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── SVG ── */}
|
||||
{xScale && (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
style={{ width: '100%', height: 'auto', display: 'block', cursor: 'crosshair' }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="inv-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={BLUE} stopOpacity="0.22" />
|
||||
<stop offset="70%" stopColor={BLUE} stopOpacity="0.06" />
|
||||
<stop offset="100%" stopColor={BLUE} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<filter id="inv-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Grille horizontale */}
|
||||
{yTicks.map(({ v, y }) => (
|
||||
<g key={v}>
|
||||
<line x1={PAD.left} y1={y} x2={W - PAD.right} y2={y}
|
||||
stroke={GRID} strokeWidth="1" strokeDasharray="3 5" />
|
||||
<text x={PAD.left - 10} y={y + 4} textAnchor="end"
|
||||
fill={LABEL} fontSize="11" fontFamily="system-ui,sans-serif">
|
||||
{fmtK(v)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Ligne zéro */}
|
||||
{yZero && yZero > PAD.top && yZero < PAD.top + plotH && (
|
||||
<line x1={PAD.left} y1={yZero} x2={W - PAD.right} y2={yZero}
|
||||
stroke="rgba(255,255,255,0.18)" strokeWidth="1" />
|
||||
)}
|
||||
|
||||
{/* Aire dégradée + courbe */}
|
||||
<path d={areaPath} fill="url(#inv-fill)" />
|
||||
<path d={linePath} fill="none" stroke={BLUE} strokeWidth="1.5"
|
||||
filter="url(#inv-glow)" strokeLinejoin="round" />
|
||||
|
||||
{/* Labels axe X */}
|
||||
{xTicks.map((p, i) => {
|
||||
const anchor = i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle';
|
||||
return (
|
||||
<text key={i} x={xScale(p.date)} y={H - 8}
|
||||
textAnchor={anchor} fill={LABEL} fontSize="10"
|
||||
fontFamily="system-ui,sans-serif">
|
||||
{fmtAxisDate(p.date, range)}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Hover : ligne verticale + point */}
|
||||
{hover && (
|
||||
<g>
|
||||
<line x1={hover.x} y1={PAD.top} x2={hover.x} y2={PAD.top + plotH}
|
||||
stroke="rgba(255,255,255,0.12)" strokeWidth="1" strokeDasharray="3 4" />
|
||||
<circle cx={hover.x} cy={hover.y} r="4.5"
|
||||
fill={BLUE} stroke={BG} strokeWidth="2" />
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* ── Tooltip flottant ── */}
|
||||
{hover && tooltipStyle && (
|
||||
<div style={tooltipStyle}>
|
||||
<div className="sg-tooltip">
|
||||
<span className="sg-tooltip-date">{tooltipDate}</span>
|
||||
<span className="sg-tooltip-value">{fmtValueDisplay(hover.value)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { fmtEUR, fmtStatut } from '../utils/format.js';
|
||||
|
||||
const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||||
|
||||
function endOfMonth(Y, M) {
|
||||
const d = new Date(Y, M, 0);
|
||||
return `${Y}-${String(M).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||
}
|
||||
function startOfMonth(Y, M) {
|
||||
return `${Y}-${String(M).padStart(2,'0')}-01`;
|
||||
}
|
||||
|
||||
const STATUT_BG = {
|
||||
en_cours: 'var(--b-en_cours-bg)',
|
||||
rembourse: 'var(--b-rembourse-bg)',
|
||||
en_retard: 'var(--b-en_retard-bg)',
|
||||
procedure: 'var(--b-procedure-bg)',
|
||||
cloture: 'var(--surface-2)',
|
||||
};
|
||||
const STATUT_FG = {
|
||||
en_cours: 'var(--b-en_cours-fg)',
|
||||
rembourse: 'var(--b-rembourse-fg)',
|
||||
en_retard: 'var(--b-en_retard-fg)',
|
||||
procedure: 'var(--b-procedure-fg)',
|
||||
cloture: 'var(--text-muted)',
|
||||
};
|
||||
|
||||
export default function InvMensuelTable({ rows, allRembs, allReinvests, year }) {
|
||||
const navigate = useNavigate();
|
||||
const currentYear = new Date().getFullYear();
|
||||
const currentMonth = new Date().getMonth() + 1;
|
||||
const displayYear = year ? Number(year) : currentYear;
|
||||
|
||||
/* ── Precompute rembs ── */
|
||||
const reinvestByInv = useMemo(() => {
|
||||
const map = {};
|
||||
for (const rv of (allReinvests || [])) {
|
||||
const id = rv.investissement_id;
|
||||
if (!id) continue;
|
||||
if (!map[id]) map[id] = [];
|
||||
map[id].push({ date: rv.date_reinvestissement?.slice(0,10), montant: rv.montant || 0 });
|
||||
}
|
||||
return map;
|
||||
}, [allReinvests]);
|
||||
|
||||
const capRembByInv = useMemo(() => {
|
||||
const map = {};
|
||||
for (const rb of (allRembs || [])) {
|
||||
const id = rb.investissement_id;
|
||||
if (!id || rb.type !== 'normal') continue;
|
||||
if (!map[id]) map[id] = [];
|
||||
map[id].push({ date: rb.date_remb?.slice(0,10), capital: rb.capital || 0 });
|
||||
}
|
||||
return map;
|
||||
}, [allRembs]);
|
||||
|
||||
const lastRembDateMap = useMemo(() => {
|
||||
const map = {};
|
||||
for (const rb of (allRembs || [])) {
|
||||
const id = rb.investissement_id;
|
||||
const d = rb.date_remb?.slice(0,10);
|
||||
if (!id || !d) continue;
|
||||
if (!map[id] || d > map[id]) map[id] = d;
|
||||
}
|
||||
return map;
|
||||
}, [allRembs]);
|
||||
|
||||
/* ── Capital encours d'un investissement à fin de mois M ── */
|
||||
const getCapital = (inv, Y, M) => {
|
||||
const endM = endOfMonth(Y, M);
|
||||
if (inv.date_souscription > endM) return 0;
|
||||
const startM = startOfMonth(Y, M);
|
||||
const ACTIVE = ['en_cours', 'en_retard', 'procedure'];
|
||||
const isActive = ACTIVE.includes(inv.statut) ||
|
||||
((inv.date_cible || lastRembDateMap[inv.id] || null) >= startM);
|
||||
if (!isActive) return 0;
|
||||
|
||||
const reinvM = (reinvestByInv[inv.id] || [])
|
||||
.filter(rv => rv.date && rv.date <= endM)
|
||||
.reduce((s, rv) => s + rv.montant, 0);
|
||||
const capRembM = (capRembByInv[inv.id] || [])
|
||||
.filter(rb => rb.date && rb.date <= endM)
|
||||
.reduce((s, rb) => s + rb.capital, 0);
|
||||
return Math.max(0, inv.montant_investi + reinvM - capRembM);
|
||||
};
|
||||
|
||||
/* ── Grille : une ligne par investissement ── */
|
||||
const grid = useMemo(() => {
|
||||
if (!rows?.length) return [];
|
||||
return rows
|
||||
.map(inv => ({
|
||||
inv,
|
||||
months: Array.from({ length: 12 }, (_, i) => getCapital(inv, displayYear, i + 1)),
|
||||
}))
|
||||
.filter(r => r.months.some(v => v > 0))
|
||||
.sort((a, b) =>
|
||||
(a.inv.date_souscription || '') < (b.inv.date_souscription || '') ? -1 : 1
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rows, displayYear, reinvestByInv, capRembByInv, lastRembDateMap]);
|
||||
|
||||
const monthTotals = useMemo(() =>
|
||||
Array.from({ length: 12 }, (_, i) => grid.reduce((s, r) => s + r.months[i], 0)),
|
||||
[grid]
|
||||
);
|
||||
|
||||
if (!grid.length) {
|
||||
return (
|
||||
<div style={{ padding: '24px', color: 'var(--text-muted)', fontSize: 'var(--fs-sm)', textAlign: 'center' }}>
|
||||
Aucun investissement actif pour {displayYear}.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: 'auto', position: 'relative', zIndex: 0 }}>
|
||||
<table className="tip-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="tip-th-empty" style={{ minWidth: 200 }} />
|
||||
<th className="tip-th-empty" style={{ minWidth: 90 }} />
|
||||
<th className="tip-th-year" colSpan={12}>{displayYear}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="tip-th-name tip-th-name-amber" style={{ minWidth: '22ch', maxWidth: '40ch' }}>Investissement</th>
|
||||
<th style={{
|
||||
padding: '7px 10px', background: 'var(--surface-2)', color: 'var(--text)',
|
||||
fontWeight: 600, fontSize: 'var(--fs-xs)', textAlign: 'left',
|
||||
borderRight: '1px solid var(--border)', whiteSpace: 'nowrap',
|
||||
}}>Statut</th>
|
||||
{MOIS_LONG.map((m, i) => (
|
||||
<th key={m}
|
||||
className={`tip-th-month${displayYear === currentYear && i === currentMonth - 1 ? ' tip-th-month-current' : ''}`}>
|
||||
{m}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{grid.map(({ inv, months }) => (
|
||||
<tr key={inv.id} className="tip-row-plat"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/investissements/${inv.id}`)}>
|
||||
|
||||
<td className="tip-td-name" style={{ whiteSpace: 'normal', maxWidth: '40ch', wordBreak: 'break-word' }}>
|
||||
{inv.nom_projet || '—'}
|
||||
</td>
|
||||
<td style={{ padding: '8px 10px', whiteSpace: 'nowrap', borderRight: '1px solid var(--border)' }}>
|
||||
<span style={{
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: 4,
|
||||
fontSize: 'var(--fs-xs)', fontWeight: 600,
|
||||
background: STATUT_BG[inv.statut] || 'var(--surface-2)',
|
||||
color: STATUT_FG[inv.statut] || 'var(--text-muted)',
|
||||
}}>
|
||||
{fmtStatut(inv.statut)}
|
||||
</span>
|
||||
</td>
|
||||
{months.map((v, mi) => {
|
||||
const curClass = displayYear === currentYear && mi === currentMonth - 1 ? ' tip-col-current' : '';
|
||||
if (v === 0) {
|
||||
// Avant la date de souscription
|
||||
const subYear = Number(inv.date_souscription?.slice(0, 4));
|
||||
const subMo = Number(inv.date_souscription?.slice(5, 7)) - 1;
|
||||
const isBefore = inv.date_souscription && (
|
||||
subYear > displayYear || (subYear === displayYear && mi < subMo)
|
||||
);
|
||||
// Après le dernier remboursement (prêt remboursé)
|
||||
const lastDate = lastRembDateMap[inv.id];
|
||||
const isAfter = inv.statut === 'rembourse' && lastDate && (() => {
|
||||
const lastYear = Number(lastDate.slice(0, 4));
|
||||
const lastMo = Number(lastDate.slice(5, 7)) - 1;
|
||||
if (lastYear < displayYear) return true;
|
||||
if (lastYear === displayYear) return mi > lastMo;
|
||||
return false;
|
||||
})();
|
||||
if (isBefore || isAfter) {
|
||||
return <td key={mi} className={`tip-td-closed${curClass}`} />;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<td key={mi} className={`tip-td-num${curClass}`}>
|
||||
{v > 0 ? fmtEUR(v) : <span className="tip-dash">—</span>}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="tip-footer-total">
|
||||
<td className="tip-td-name">Total</td>
|
||||
<td />
|
||||
{monthTotals.map((v, i) => (
|
||||
<td key={i}
|
||||
className={`tip-td-num${displayYear === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`}>
|
||||
{v > 0 ? fmtEUR(v) : <span className="tip-dash">—</span>}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
|
||||
/**
|
||||
* InvSelect — multi-select with checkboxes + inline "Add item"
|
||||
* Generic replacement for CategorySelect, works with categories_inv / secteurs_inv.
|
||||
*
|
||||
* Props:
|
||||
* items : { id, nom, is_global }[] — liste complète fournie par le parent
|
||||
* selected : number[] — ids sélectionnés
|
||||
* onChange : (ids: number[]) => void
|
||||
* addApiPath : string — ex. '/categories-inv' | '/secteurs-inv'
|
||||
* onItemAdded : ({ id, nom, is_global }) => void — appelé après création inline
|
||||
* emptyLabel : string — texte si rien de sélectionné
|
||||
* addLabel : string — texte du bouton "Ajouter"
|
||||
* inputPlaceholder : string — placeholder du champ de création
|
||||
*/
|
||||
export default function InvSelect({
|
||||
items = [],
|
||||
selected = [],
|
||||
onChange,
|
||||
addApiPath,
|
||||
onItemAdded,
|
||||
emptyLabel = 'Aucun élément sélectionné',
|
||||
addLabel = 'Ajouter un élément',
|
||||
inputPlaceholder = 'Nom…',
|
||||
inheritedIds = [],
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [err, setErr] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 0 });
|
||||
|
||||
const wrapRef = useRef(null);
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !triggerRef.current) return;
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
setDropPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e) => {
|
||||
if (wrapRef.current?.contains(e.target)) return;
|
||||
const drop = document.getElementById('inv-select-dropdown-portal');
|
||||
if (drop?.contains(e.target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
const closeOnScroll = (e) => {
|
||||
const drop = document.getElementById('inv-select-dropdown-portal');
|
||||
if (drop?.contains(e.target)) return; // scroll dans le dropdown — on garde ouvert
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', close);
|
||||
window.addEventListener('scroll', closeOnScroll, true);
|
||||
window.addEventListener('resize', closeOnScroll);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', close);
|
||||
window.removeEventListener('scroll', closeOnScroll, true);
|
||||
window.removeEventListener('resize', closeOnScroll);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const toggle = (id) => {
|
||||
if (inheritedIds.includes(id)) return; // tag hérité du référentiel, non modifiable
|
||||
onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]);
|
||||
};
|
||||
|
||||
const addItem = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newName.trim()) return;
|
||||
setBusy(true); setErr(null);
|
||||
try {
|
||||
const item = await api.post(addApiPath, { nom: newName.trim() });
|
||||
onItemAdded?.(item);
|
||||
onChange([...selected, item.id]);
|
||||
setNewName('');
|
||||
setAdding(false);
|
||||
} catch (e) {
|
||||
setErr(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerLabel = (() => {
|
||||
if (selected.length === 0) return emptyLabel;
|
||||
const names = items.filter(c => selected.includes(c.id)).map(c => c.nom);
|
||||
if (names.length <= 2) return names.join(', ');
|
||||
return `${names.length} éléments sélectionnés`;
|
||||
})();
|
||||
|
||||
const dropdown = open ? (
|
||||
<div
|
||||
id="inv-select-dropdown-portal"
|
||||
className="cat-select-dropdown"
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
style={{ position: 'fixed', top: dropPos.top, left: dropPos.left, width: dropPos.width, zIndex: 9999 }}
|
||||
>
|
||||
{items.length === 0 && (
|
||||
<div className="cat-select-empty">{emptyLabel}</div>
|
||||
)}
|
||||
{items.map(item => {
|
||||
const checked = selected.includes(item.id);
|
||||
const inherited = inheritedIds.includes(item.id);
|
||||
return (
|
||||
<label key={item.id} className={`cat-select-item${checked ? ' checked' : ''}${inherited ? ' inherited' : ''}`}
|
||||
title={inherited ? 'Hérité du référentiel — non modifiable' : undefined}>
|
||||
<input type="checkbox" checked={checked || inherited} disabled={inherited} onChange={() => toggle(item.id)} />
|
||||
<span>{item.nom}</span>
|
||||
{inherited
|
||||
? <span style={{ marginLeft: 'auto', fontSize: 10, fontWeight: 600, padding: '1px 5px',
|
||||
borderRadius: 3, background: 'var(--accent)', color: '#fff', opacity: .85 }}>Réf</span>
|
||||
: checked && (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ marginLeft: 'auto', color: 'var(--accent)' }} aria-hidden="true">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
|
||||
{addApiPath && (
|
||||
<>
|
||||
<div className="cat-select-sep" />
|
||||
{!adding ? (
|
||||
<button type="button" className="cat-select-add-btn"
|
||||
onClick={() => { setAdding(true); setErr(null); }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
aria-hidden="true">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
{addLabel}
|
||||
</button>
|
||||
) : (
|
||||
<form onSubmit={addItem} className="cat-select-new-form">
|
||||
<input
|
||||
autoFocus
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
placeholder={inputPlaceholder}
|
||||
maxLength={200}
|
||||
/>
|
||||
<div className="cat-select-new-actions">
|
||||
<button type="submit" className="primary" disabled={busy || !newName.trim()}>
|
||||
{busy ? '…' : 'Créer'}
|
||||
</button>
|
||||
<button type="button" className="ghost"
|
||||
onClick={() => { setAdding(false); setNewName(''); setErr(null); }}>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
{err && <div className="cat-select-err">{err}</div>}
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={wrapRef} className="cat-select-wrap">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={`cat-select-trigger${open ? ' open' : ''}`}
|
||||
onClick={() => { setOpen(o => !o); setAdding(false); setErr(null); }}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="cat-select-label">{triggerLabel}</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ flexShrink: 0, transition: 'transform .15s', transform: open ? 'rotate(0deg)' : 'rotate(180deg)' }}
|
||||
aria-hidden="true">
|
||||
<path d="M18 15l-6-6-6 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{dropdown}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
import { useUi } from '../context/UiContext.jsx';
|
||||
import Logo from './Logo.jsx';
|
||||
import UserMenu from './UserMenu.jsx';
|
||||
|
||||
/* ── Icônes nav ─────────────────────────────────────────────── */
|
||||
const ICONS_BASE = '/api/icons-files/';
|
||||
|
||||
const I = ({ children }) => (
|
||||
<svg width="16" height="16" viewBox="0 0 18 18" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"
|
||||
aria-hidden="true">
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconDashboard = () => <I><rect x="1" y="1" width="7" height="7" rx="1.5"/><rect x="10" y="1" width="7" height="7" rx="1.5"/><rect x="1" y="10" width="7" height="7" rx="1.5"/><rect x="10" y="10" width="7" height="7" rx="1.5"/></I>;
|
||||
const IconDeposits = () => <I><line x1="6" y1="14" x2="6" y2="4"/><polyline points="3,7 6,4 9,7"/><line x1="12" y1="4" x2="12" y2="14"/><polyline points="9,11 12,14 15,11"/></I>;
|
||||
const IconInvestments = () => <I><polyline points="1,15 5,9 9,11 15,3"/><polyline points="11,3 15,3 15,7"/></I>;
|
||||
const IconRepayments = () => <I><path d="M15 9A6 6 0 1 1 9 3"/><polyline points="15,3 15,9 9,9"/></I>;
|
||||
const IconFlatTax = () => <I><circle cx="9" cy="9" r="7.5"/><path d="M9 1.5a11 11 0 0 1 3.5 7.5 11 11 0 0 1-3.5 7.5 11 11 0 0 1-3.5-7.5 11 11 0 0 1 3.5-7.5z"/><line x1="1.5" y1="9" x2="16.5" y2="9"/></I>;
|
||||
|
||||
/* Icône nav hybride : bibliothèque si dispo, sinon fallback SVG inline */
|
||||
function NavIcon({ libFilename, Fallback }) {
|
||||
if (libFilename) {
|
||||
return (
|
||||
<img
|
||||
src={`${ICONS_BASE}${libFilename}`}
|
||||
width="24" height="24"
|
||||
className="nav-lib-icon"
|
||||
aria-hidden="true"
|
||||
alt=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Fallback />;
|
||||
}
|
||||
|
||||
/* Bouton « réduire » (visible dans la sidebar étendue) */
|
||||
const IconPanelCollapse = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 20 18" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
aria-hidden="true">
|
||||
<rect x=".75" y=".75" width="18.5" height="16.5" rx="2.5"/>
|
||||
<line x1="7" y1=".75" x2="7" y2="17.25"/>
|
||||
<path d="M13 5.5 L10 9 L13 12.5"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/* Bouton « étendre » (apparaît au hover du logo en mode réduit) */
|
||||
const IconPanelExpand = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 20 18" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
aria-hidden="true">
|
||||
<rect x=".75" y=".75" width="18.5" height="16.5" rx="2.5"/>
|
||||
<line x1="7" y1=".75" x2="7" y2="17.25"/>
|
||||
<path d="M3 5.5 L6 9 L3 12.5"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/* ── Recherche rapide de projet ─────────────────────────────── */
|
||||
function ProjectSearch() {
|
||||
const navigate = useNavigate();
|
||||
const { activeId, activeView } = useInvestisseur();
|
||||
const [query, setQuery] = useState('');
|
||||
const [allInv, setAllInv] = useState([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIdx, setActiveIdx] = useState(-1);
|
||||
const inputRef = useRef(null);
|
||||
const wrapRef = useRef(null);
|
||||
|
||||
/* Chargement (ou rechargement) des investissements */
|
||||
const loadInv = useCallback(async () => {
|
||||
try {
|
||||
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||
const rows = await api.get('/investissements', scopeParams);
|
||||
setAllInv(rows);
|
||||
} catch {}
|
||||
}, [activeView]);
|
||||
|
||||
useEffect(() => { loadInv(); }, [loadInv, activeId]);
|
||||
|
||||
/* Raccourci clavier global Ctrl+K / Cmd+K */
|
||||
useEffect(() => {
|
||||
const h = e => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', h);
|
||||
return () => document.removeEventListener('keydown', h);
|
||||
}, []);
|
||||
|
||||
/* Fermeture au clic extérieur */
|
||||
useEffect(() => {
|
||||
const h = e => { if (!wrapRef.current?.contains(e.target)) setOpen(false); };
|
||||
document.addEventListener('mousedown', h);
|
||||
return () => document.removeEventListener('mousedown', h);
|
||||
}, []);
|
||||
|
||||
/* Résultats filtrés */
|
||||
const results = (() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
return allInv
|
||||
.filter(r => r.nom_projet?.toLowerCase().includes(q) || r.plateforme_nom?.toLowerCase().includes(q))
|
||||
.slice(0, 8);
|
||||
})();
|
||||
|
||||
/* Synchronise l'ouverture du dropdown */
|
||||
useEffect(() => {
|
||||
setOpen(results.length > 0 && query.trim().length > 0);
|
||||
setActiveIdx(-1);
|
||||
}, [results.length, query]); /* eslint-disable-line */
|
||||
|
||||
const goTo = (inv) => {
|
||||
setQuery(''); setOpen(false);
|
||||
navigate(`/investissements/${inv.id}`);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIdx(i => Math.min(i + 1, results.length - 1)); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIdx(i => Math.max(i - 1, -1)); }
|
||||
if (e.key === 'Enter') { e.preventDefault(); if (activeIdx >= 0) goTo(results[activeIdx]); else if (results.length === 1) goTo(results[0]); }
|
||||
if (e.key === 'Escape') { setOpen(false); setQuery(''); inputRef.current?.blur(); }
|
||||
};
|
||||
|
||||
const STATUT_LABELS = {
|
||||
en_cours: 'en cours',
|
||||
rembourse: 'remboursé',
|
||||
en_retard: 'en retard',
|
||||
procedure: 'procédure',
|
||||
cloture: 'clôturé',
|
||||
};
|
||||
|
||||
const statutColor = (s) => {
|
||||
if (s === 'en_cours') return 'var(--b-en_cours-fg)';
|
||||
if (s === 'rembourse') return 'var(--b-rembourse-fg)';
|
||||
if (s === 'en_retard') return 'var(--b-en_retard-fg)';
|
||||
if (s === 'procedure') return 'var(--b-procedure-fg)';
|
||||
return 'var(--text-muted)';
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} style={{ position: 'relative' }}>
|
||||
<div className="project-search-wrap">
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ opacity: 0.7, flexShrink: 0 }}>
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="project-search-input"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Rechercher un projet…"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
{query ? (
|
||||
<button className="project-search-clear"
|
||||
onMouseDown={e => { e.preventDefault(); setQuery(''); setOpen(false); inputRef.current?.focus(); }}>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.8" strokeLinecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<kbd className="project-search-kbd">⌘K</kbd>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="project-search-dropdown">
|
||||
{results.map((inv, i) => (
|
||||
<div key={inv.id}
|
||||
className={`project-search-item${activeIdx === i ? ' active' : ''}`}
|
||||
onMouseDown={() => goTo(inv)}
|
||||
onMouseEnter={() => setActiveIdx(i)}
|
||||
>
|
||||
<div className="project-search-item-name">{inv.nom_projet}</div>
|
||||
<div className="project-search-item-meta">
|
||||
<span>{inv.plateforme_nom}</span>
|
||||
<span>·</span>
|
||||
<span style={{ color: statutColor(inv.statut) }}>{STATUT_LABELS[inv.statut] ?? inv.statut?.replace('_', ' ')}</span>
|
||||
{inv.montant_investi != null && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{inv.montant_investi.toLocaleString('fr-FR')} €</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Layout ─────────────────────────────────────────────────── */
|
||||
function IconPlusCircle() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
|
||||
aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="16"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const { sidebarCollapsed, toggleSidebar, displayMode, setDisplayMode } = useUi();
|
||||
const navigate = useNavigate();
|
||||
const [navIcons, setNavIcons] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/icons').then(rows => {
|
||||
const m = {};
|
||||
rows.forEach(r => { m[r.name] = r.filename; });
|
||||
setNavIcons(m);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`app-shell${sidebarCollapsed ? ' sidebar-collapsed' : ''}`}>
|
||||
<aside className="sidebar">
|
||||
|
||||
{/* ── Header ─────────────────────────────────────────── */}
|
||||
<div className="sidebar-brand">
|
||||
<div className="sidebar-brand-logo">
|
||||
<Logo size={34} />
|
||||
</div>
|
||||
<span className="sidebar-brand-text">Crowdlending</span>
|
||||
|
||||
{/* Réduire (visible seulement en mode étendu) */}
|
||||
<button
|
||||
className="sidebar-panel-btn"
|
||||
onClick={toggleSidebar}
|
||||
title="Réduire le menu"
|
||||
aria-label="Réduire le menu"
|
||||
>
|
||||
<IconPanelCollapse />
|
||||
</button>
|
||||
|
||||
{/* Étendre — overlay au hover du logo en mode réduit */}
|
||||
<button
|
||||
className="sidebar-expand-overlay"
|
||||
onClick={toggleSidebar}
|
||||
title="Ouvrir le menu"
|
||||
aria-label="Ouvrir le menu"
|
||||
>
|
||||
<IconPanelExpand />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Navigation ─────────────────────────────────────── */}
|
||||
<nav className="sidebar-nav">
|
||||
<NavLink to="/" end title="Tableau de bord">
|
||||
<NavIcon libFilename={navIcons.dashboard} Fallback={IconDashboard} /><span className="nav-label">Tableau de bord</span>
|
||||
</NavLink>
|
||||
<NavLink to="/plateformes" title="Plateformes">
|
||||
<NavIcon libFilename={navIcons.plateforme} Fallback={IconInvestments} /><span className="nav-label">Plateformes</span>
|
||||
</NavLink>
|
||||
<NavLink to="/investissements" title="Investissements">
|
||||
<NavIcon libFilename={navIcons.investissement} Fallback={IconInvestments} /><span className="nav-label">Investissements</span>
|
||||
</NavLink>
|
||||
<NavLink to="/depots-retraits" title="Dépôts / Retraits">
|
||||
<NavIcon libFilename={navIcons['depots-retraits']} Fallback={IconDeposits} /><span className="nav-label">Dépôts / Retraits</span>
|
||||
</NavLink>
|
||||
<NavLink to="/remboursements" title="Remboursements">
|
||||
<NavIcon libFilename={navIcons.remboursement} Fallback={IconRepayments} /><span className="nav-label">Remboursements</span>
|
||||
</NavLink>
|
||||
<NavLink to="/taxreport" title="Fiscalité">
|
||||
<NavIcon libFilename={navIcons.tax} Fallback={IconFlatTax} /><span className="nav-label">Fiscalité</span>
|
||||
</NavLink>
|
||||
|
||||
</nav>
|
||||
|
||||
{/* ── Pied : menu utilisateur ─────────────────────────── */}
|
||||
<UserMenu />
|
||||
</aside>
|
||||
|
||||
<main className="main">
|
||||
<div className="topbar topbar-global">
|
||||
<ProjectSearch />
|
||||
<div className="topbar-right">
|
||||
<button
|
||||
className="btn-add-invest"
|
||||
onClick={() => navigate('/investissements?new=1')}
|
||||
>
|
||||
<IconPlusCircle />
|
||||
Ajout Investissement
|
||||
</button>
|
||||
<button
|
||||
className="btn-add-invest"
|
||||
onClick={() => navigate('/remboursements?new=1')}
|
||||
>
|
||||
<IconPlusCircle />
|
||||
Nouveau remboursement
|
||||
</button>
|
||||
<button
|
||||
className="btn-add-invest"
|
||||
onClick={() => navigate('/depots-retraits?new=1')}
|
||||
>
|
||||
<IconPlusCircle />
|
||||
Nouveau dépôt/retrait
|
||||
</button>
|
||||
|
||||
<div className="display-toggle" role="group" aria-label="Mode d'affichage">
|
||||
<button
|
||||
className={`display-toggle-btn${displayMode === 'brut' ? ' active' : ''}`}
|
||||
onClick={() => setDisplayMode('brut')}
|
||||
aria-pressed={displayMode === 'brut'}
|
||||
>
|
||||
Brut
|
||||
</button>
|
||||
<button
|
||||
className={`display-toggle-btn${displayMode === 'net' ? ' active' : ''}`}
|
||||
onClick={() => setDisplayMode('net')}
|
||||
aria-pressed={displayMode === 'net'}
|
||||
>
|
||||
Net
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export default function Logo({ size = 32 }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 48 48"
|
||||
width={size}
|
||||
height={size}
|
||||
style={{ display: 'block', flexShrink: 0 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect width="48" height="48" rx="10" fill="#1e3a8a" />
|
||||
<rect x="7" y="31" width="9" height="11" rx="2" fill="#93c5fd" />
|
||||
<rect x="20" y="21" width="9" height="21" rx="2" fill="#60a5fa" />
|
||||
<rect x="33" y="11" width="9" height="31" rx="2" fill="white" />
|
||||
<polygon points="37.5,4 44,12 31,12" fill="#4ade80" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export default function Modal({ open, title, onClose, children, footer, width = 600 }) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="modal-backdrop">
|
||||
<div
|
||||
className="card"
|
||||
style={{ width: '100%', maxWidth: width, maxHeight: '90vh', overflow: 'auto' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<h3 style={{ margin: 0 }}>{title}</h3>
|
||||
<button className="ghost" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
{children}
|
||||
{footer && <div style={{ marginTop: 16, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const ICONS_BASE = '/api/icons-files/';
|
||||
|
||||
let _cache = null;
|
||||
let _promise = null;
|
||||
|
||||
function getIcons() {
|
||||
if (_cache) return Promise.resolve(_cache);
|
||||
if (!_promise) {
|
||||
_promise = api.get('/icons')
|
||||
.then(rows => { _cache = {}; rows.forEach(r => { _cache[r.name] = r.filename; }); return _cache; })
|
||||
.catch(() => { _cache = {}; return _cache; });
|
||||
}
|
||||
return _promise;
|
||||
}
|
||||
|
||||
export default function PageIcon({ name, size = 40 }) {
|
||||
const [filename, setFilename] = useState(() => _cache?.[name] ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (_cache) { setFilename(_cache[name] ?? null); return; }
|
||||
getIcons().then(m => setFilename(m[name] ?? null));
|
||||
}, [name]);
|
||||
|
||||
if (!filename) return null;
|
||||
return (
|
||||
<img
|
||||
src={`${ICONS_BASE}${filename}`}
|
||||
width={size}
|
||||
height={size}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
display: 'inline',
|
||||
verticalAlign: 'middle',
|
||||
marginRight: 10,
|
||||
objectFit: 'contain',
|
||||
opacity: 0.85,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Barre de pagination réutilisable.
|
||||
* Props : page, setPage, pageSize, setPageSize, totalPages, totalItems, PAGE_SIZES
|
||||
*/
|
||||
export default function Pagination({ page, setPage, pageSize, setPageSize, totalPages, totalItems, PAGE_SIZES }) {
|
||||
if (totalItems === 0) return null;
|
||||
|
||||
const start = (page - 1) * pageSize + 1;
|
||||
const end = Math.min(page * pageSize, totalItems);
|
||||
|
||||
return (
|
||||
<div className="pagination-bar">
|
||||
<span className="pagination-info">
|
||||
{start}–{end} sur {totalItems}
|
||||
</span>
|
||||
|
||||
<div className="pagination-controls">
|
||||
<label className="pagination-size-label">
|
||||
Lignes :
|
||||
<select
|
||||
className="pagination-size-select"
|
||||
value={pageSize}
|
||||
onChange={e => setPageSize(Number(e.target.value))}
|
||||
>
|
||||
{PAGE_SIZES.map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setPage(1)}
|
||||
disabled={page === 1}
|
||||
title="Première page"
|
||||
>«</button>
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
title="Page précédente"
|
||||
>‹</button>
|
||||
<span className="pagination-pages">{page} / {totalPages}</span>
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
title="Page suivante"
|
||||
>›</button>
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setPage(totalPages)}
|
||||
disabled={page === totalPages}
|
||||
title="Dernière page"
|
||||
>»</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* ResultBanner — bannière succès/erreur avec auto-dismiss et × à droite.
|
||||
*
|
||||
* Props:
|
||||
* result : { ok: bool, msg: string } | null
|
||||
* onDismiss : () => void — appelé à la fermeture (manuelle ou auto)
|
||||
* delay : number — délai auto-dismiss en ms (défaut 4000)
|
||||
*/
|
||||
export default function ResultBanner({ result, onDismiss, delay = 4000, style = {} }) {
|
||||
useEffect(() => {
|
||||
if (!result) return;
|
||||
const t = setTimeout(onDismiss, delay);
|
||||
return () => clearTimeout(t);
|
||||
}, [result, delay, onDismiss]);
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 14px',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: result.ok ? 'rgba(34,197,94,.1)' : 'rgba(239,68,68,.1)',
|
||||
color: result.ok ? '#16a34a' : '#dc2626',
|
||||
border: `1px solid ${result.ok ? 'rgba(34,197,94,.3)' : 'rgba(239,68,68,.3)'}`,
|
||||
...style,
|
||||
}}>
|
||||
<span>{result.msg}</span>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
style={{
|
||||
marginLeft: 16,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'inherit',
|
||||
fontSize: 18,
|
||||
lineHeight: 1,
|
||||
padding: '0 2px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
aria-label="Fermer"
|
||||
>×</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { useMemo, useState, useRef, useEffect } from 'react';
|
||||
|
||||
/* ── Constantes ─────────────────────────────────────────────── */
|
||||
const GOLD = '#4fa8e8'; // bleu ciel — accord avec le thème navy du site
|
||||
const BG = '#070c15';
|
||||
const GRID = 'rgba(255,255,255,0.055)';
|
||||
const LABEL = '#4a5568';
|
||||
|
||||
const RANGES = ['1J', '7J', '1M', '3M', 'YTD', '1A', 'TOUT'];
|
||||
|
||||
const MOIS_COURT = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.'];
|
||||
|
||||
/* ── Helpers ────────────────────────────────────────────────── */
|
||||
function fmtK(v) {
|
||||
if (v === 0) return '0 €';
|
||||
const abs = Math.abs(v);
|
||||
if (abs >= 1000) return (v / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' k €';
|
||||
return v.toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' €';
|
||||
}
|
||||
|
||||
function fmtAxisDate(dateStr, range) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const mon = MOIS_COURT[d.getMonth()];
|
||||
const yr = d.getFullYear();
|
||||
if (range === '1J' || range === '7J') return `${day}/${String(d.getMonth()+1).padStart(2,'0')}`;
|
||||
if (range === '1M') return `${day} ${mon}`;
|
||||
if (range === '3M') return `${day} ${mon}`;
|
||||
return `${day}/${String(d.getMonth()+1).padStart(2,'0')}/${String(yr).slice(2)}`;
|
||||
}
|
||||
|
||||
function fmtValueDisplay(v) {
|
||||
return v.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }) + ' €';
|
||||
}
|
||||
|
||||
function fmtTodayFull() {
|
||||
return new Date().toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
/* ── Composant ──────────────────────────────────────────────── */
|
||||
export default function SoldeChart({ rows }) {
|
||||
const [range, setRange] = useState('TOUT');
|
||||
const [hover, setHover] = useState(null); // { x, y, value, date }
|
||||
const svgRef = useRef(null);
|
||||
const wrapRef = useRef(null);
|
||||
|
||||
/* ── 1. Cumul complet (toutes les données) ── */
|
||||
const allPoints = useMemo(() => {
|
||||
if (!rows?.length) return [];
|
||||
const byDate = {};
|
||||
for (const r of rows) {
|
||||
const d = r.date_operation.slice(0, 10);
|
||||
byDate[d] = (byDate[d] || 0) + (r.type === 'depot' ? r.montant : -r.montant);
|
||||
}
|
||||
let cum = 0;
|
||||
return Object.keys(byDate).sort().map(date => {
|
||||
cum += byDate[date];
|
||||
return { date, value: cum };
|
||||
});
|
||||
}, [rows]);
|
||||
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
const currentValue = allPoints.length ? allPoints[allPoints.length - 1].value : 0;
|
||||
|
||||
/* ── 2. Filtrage par plage ── */
|
||||
const points = useMemo(() => {
|
||||
if (!allPoints.length) return [];
|
||||
if (range === 'TOUT') {
|
||||
const pts = [...allPoints];
|
||||
if (pts[pts.length - 1].date < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value });
|
||||
return pts;
|
||||
}
|
||||
const now = new Date();
|
||||
let fromDate = new Date(now);
|
||||
switch (range) {
|
||||
case '1J': fromDate.setDate(now.getDate() - 1); break;
|
||||
case '7J': fromDate.setDate(now.getDate() - 7); break;
|
||||
case '1M': fromDate.setMonth(now.getMonth() - 1); break;
|
||||
case '3M': fromDate.setMonth(now.getMonth() - 3); break;
|
||||
case 'YTD': fromDate = new Date(now.getFullYear(), 0, 1); break;
|
||||
case '1A': fromDate.setFullYear(now.getFullYear() - 1); break;
|
||||
}
|
||||
const fromStr = fromDate.toISOString().slice(0, 10);
|
||||
const before = allPoints.filter(p => p.date < fromStr);
|
||||
const startV = before.length ? before[before.length - 1].value : 0;
|
||||
const after = allPoints.filter(p => p.date >= fromStr);
|
||||
const pts = [{ date: fromStr, value: startV }, ...after];
|
||||
if (pts[pts.length - 1].date < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value });
|
||||
return pts;
|
||||
}, [allPoints, range, todayStr]);
|
||||
|
||||
/* ── 3. SVG dimensions ── */
|
||||
const W = 900, H = 260;
|
||||
const PAD = { top: 16, right: 16, bottom: 32, left: 70 };
|
||||
const plotW = W - PAD.left - PAD.right;
|
||||
const plotH = H - PAD.top - PAD.bottom;
|
||||
|
||||
/* ── 4. Échelles ── */
|
||||
const { xScale, yScale, yTicks, xTicks, minDate, maxDate, yZero } = useMemo(() => {
|
||||
if (points.length < 2) return {};
|
||||
const vals = points.map(p => p.value);
|
||||
const dataMin = Math.min(...vals);
|
||||
const dataMax = Math.max(...vals);
|
||||
// Inclure 0 pour ancrer l'axe ; ajouter 10 % de padding
|
||||
const lo = Math.min(0, dataMin);
|
||||
const hi = Math.max(0, dataMax);
|
||||
const pad = (hi - lo) * 0.1 || 10;
|
||||
const scaleLo = lo - (lo < 0 ? pad : 0);
|
||||
const scaleHi = hi + (hi > 0 ? pad : pad);
|
||||
const valRange = scaleHi - scaleLo || 1;
|
||||
|
||||
const ts = points.map(p => new Date(p.date + 'T00:00:00').getTime());
|
||||
const minDt = ts[0];
|
||||
const maxDt = ts[ts.length - 1];
|
||||
const dtRange = maxDt - minDt || 1;
|
||||
|
||||
const xScale = d => PAD.left + ((new Date(d + 'T00:00:00').getTime() - minDt) / dtRange) * plotW;
|
||||
const yScale = v => PAD.top + plotH - ((v - scaleLo) / valRange) * plotH;
|
||||
|
||||
// Y ticks : 5 niveaux couvrant la plage réelle
|
||||
const step = (scaleHi - scaleLo) / 4;
|
||||
const yTicks = Array.from({ length: 5 }, (_, i) => ({ v: scaleLo + step * i, y: yScale(scaleLo + step * i) }));
|
||||
|
||||
// X ticks : max 8
|
||||
const nX = Math.min(8, points.length);
|
||||
const xTicks = Array.from({ length: nX }, (_, i) => {
|
||||
const idx = Math.round((i / (nX - 1)) * (points.length - 1));
|
||||
return points[idx];
|
||||
});
|
||||
|
||||
return { xScale, yScale, yTicks, xTicks, minDate: minDt, maxDate: maxDt, yZero: yScale(0) };
|
||||
}, [points, plotW, plotH, PAD]);
|
||||
|
||||
/* ── 5. Chemins SVG ── */
|
||||
const { linePath, areaPath } = useMemo(() => {
|
||||
if (!xScale || points.length < 2) return { linePath: '', areaPath: '' };
|
||||
let line = `M ${xScale(points[0].date).toFixed(1)},${yScale(points[0].value).toFixed(1)}`;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
line += ` H ${xScale(points[i].date).toFixed(1)} V ${yScale(points[i].value).toFixed(1)}`;
|
||||
}
|
||||
// Refermer l'aire sur la ligne zéro (et non le bas du chart)
|
||||
const zeroY = yZero?.toFixed(1) ?? (PAD.top + plotH).toFixed(1);
|
||||
const area = `${line} V ${zeroY} H ${PAD.left} Z`;
|
||||
return { linePath: line, areaPath: area };
|
||||
}, [points, xScale, yScale, yZero, PAD, plotH]);
|
||||
|
||||
/* ── 6. Hover ── */
|
||||
const handleMouseMove = (e) => {
|
||||
if (!svgRef.current || !xScale || points.length < 2) return;
|
||||
const rect = svgRef.current.getBoundingClientRect();
|
||||
const svgX = ((e.clientX - rect.left) / rect.width) * W;
|
||||
const t = minDate + ((svgX - PAD.left) / plotW) * (maxDate - minDate);
|
||||
// Find nearest point
|
||||
let nearest = points[0], minDiff = Infinity;
|
||||
for (const p of points) {
|
||||
const diff = Math.abs(new Date(p.date + 'T00:00:00').getTime() - t);
|
||||
if (diff < minDiff) { minDiff = diff; nearest = p; }
|
||||
}
|
||||
setHover({
|
||||
x: xScale(nearest.date),
|
||||
y: yScale(nearest.value),
|
||||
value: nearest.value,
|
||||
date: nearest.date,
|
||||
});
|
||||
};
|
||||
|
||||
/* ── Tooltip flottant : DOIT être avant tout return conditionnel (Rules of Hooks) ── */
|
||||
const tooltipStyle = useMemo(() => {
|
||||
if (!hover) return null;
|
||||
const xPct = (hover.x / W) * 100;
|
||||
const yPct = (hover.y / H) * 100;
|
||||
const anchorRight = xPct > 65;
|
||||
return {
|
||||
position: 'absolute',
|
||||
top: `calc(${yPct}% - 64px)`,
|
||||
left: anchorRight ? 'auto' : `calc(${xPct}% + 12px)`,
|
||||
right: anchorRight ? `calc(${100 - xPct}% + 12px)` : 'auto',
|
||||
transform: 'none',
|
||||
pointerEvents: 'none',
|
||||
};
|
||||
}, [hover]);
|
||||
|
||||
if (!allPoints.length) return null;
|
||||
|
||||
/* ── Date affichée dans l'en-tête (hover ou aujourd'hui) ── */
|
||||
const displayDate = hover
|
||||
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })
|
||||
: fmtTodayFull();
|
||||
const displayValue = hover ? fmtValueDisplay(hover.value) : fmtValueDisplay(currentValue);
|
||||
|
||||
const tooltipDate = hover
|
||||
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="solde-chart-wrap" ref={wrapRef} style={{ position: 'relative' }}>
|
||||
{/* ── En-tête ── */}
|
||||
<div className="solde-chart-header">
|
||||
<div className="solde-chart-info">
|
||||
<div className="solde-chart-date">{displayDate}</div>
|
||||
<div className="solde-chart-value">{displayValue}</div>
|
||||
</div>
|
||||
<div className="solde-chart-controls">
|
||||
<div className="solde-chart-ranges">
|
||||
{RANGES.map(r => (
|
||||
<button key={r}
|
||||
className={`solde-range-btn${range === r ? ' active' : ''}`}
|
||||
onClick={() => { setRange(r); setHover(null); }}>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── SVG ── */}
|
||||
{xScale && (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
style={{ width: '100%', height: 'auto', display: 'block', cursor: 'crosshair' }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="sg-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={GOLD} stopOpacity="0.22" />
|
||||
<stop offset="70%" stopColor={GOLD} stopOpacity="0.06" />
|
||||
<stop offset="100%" stopColor={GOLD} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<filter id="sg-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Grille horizontale */}
|
||||
{yTicks.map(({ v, y }) => (
|
||||
<g key={v}>
|
||||
<line x1={PAD.left} y1={y} x2={W - PAD.right} y2={y}
|
||||
stroke={GRID} strokeWidth="1" strokeDasharray="3 5" />
|
||||
<text x={PAD.left - 10} y={y + 4} textAnchor="end"
|
||||
fill={LABEL} fontSize="11" fontFamily="system-ui,sans-serif">
|
||||
{fmtK(v)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Ligne zéro */}
|
||||
{yZero && yZero > PAD.top && yZero < PAD.top + plotH && (
|
||||
<line x1={PAD.left} y1={yZero} x2={W - PAD.right} y2={yZero}
|
||||
stroke="rgba(255,255,255,0.18)" strokeWidth="1" />
|
||||
)}
|
||||
|
||||
{/* Remplissage dégradé */}
|
||||
<path d={areaPath} fill="url(#sg-fill)" />
|
||||
|
||||
{/* Ligne principale */}
|
||||
<path d={linePath} fill="none" stroke={GOLD} strokeWidth="1"
|
||||
filter="url(#sg-glow)" strokeLinejoin="round" />
|
||||
|
||||
{/* Labels axe X */}
|
||||
{xTicks.map((p, i) => {
|
||||
const anchor = i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle';
|
||||
return (
|
||||
<text key={i} x={xScale(p.date)} y={H - 8}
|
||||
textAnchor={anchor} fill={LABEL} fontSize="10"
|
||||
fontFamily="system-ui,sans-serif">
|
||||
{fmtAxisDate(p.date, range)}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Ligne verticale + point hover */}
|
||||
{hover && (
|
||||
<g>
|
||||
<line x1={hover.x} y1={PAD.top} x2={hover.x} y2={PAD.top + plotH}
|
||||
stroke="rgba(255,255,255,0.12)" strokeWidth="1" strokeDasharray="3 4" />
|
||||
<circle cx={hover.x} cy={hover.y} r="4.5"
|
||||
fill={GOLD} stroke={BG} strokeWidth="2" />
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* ── Tooltip flottant ── */}
|
||||
{hover && tooltipStyle && (
|
||||
<div style={tooltipStyle}>
|
||||
<div className="sg-tooltip">
|
||||
<span className="sg-tooltip-date">{tooltipDate}</span>
|
||||
<span className="sg-tooltip-value">{fmtValueDisplay(hover.value)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
import { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useInteretsChart } from '../context/InteretsChartContext.jsx';
|
||||
import { fmtEUR, fmtPct } from '../utils/format.js';
|
||||
|
||||
const ICONS_BASE = '/api/icons-files/';
|
||||
const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||||
|
||||
function hexToRgba(hex, a) {
|
||||
if (!hex || hex.length < 7) return `rgba(79,168,232,${a})`;
|
||||
const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16);
|
||||
return `rgba(${r},${g},${b},${a})`;
|
||||
}
|
||||
|
||||
function ChevronDown({ size = 10 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Fusionne deux maps de remboursements ou projections ── */
|
||||
function mergeMaps(mapA, mapB) {
|
||||
const result = {};
|
||||
const keys = new Set([...Object.keys(mapA || {}), ...Object.keys(mapB || {})]);
|
||||
for (const k of keys) {
|
||||
const a = mapA?.[k] || {};
|
||||
const b = mapB?.[k] || {};
|
||||
result[k] = {
|
||||
interets_bruts: (a.interets_bruts || 0) + (b.interets_bruts || 0),
|
||||
interets_nets: (a.interets_nets || 0) + (b.interets_nets || 0),
|
||||
cashback: (a.cashback || 0) + (b.cashback || 0),
|
||||
capital: (a.capital || 0) + (b.capital || 0),
|
||||
interets_prevus: (a.interets_prevus || 0) + (b.interets_prevus || 0),
|
||||
capital_prevu: (a.capital_prevu || 0) + (b.capital_prevu || 0),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export default function TableauInteretsPlateforme({ activeView, activeId, pfuRates, onCapitalMensuel, expandButton, onCellClick, activeCell }) {
|
||||
const {
|
||||
annee, setAnnee, availableYears,
|
||||
inclureInterets, setInclureInterets,
|
||||
inclureCapital, setInclureCapital,
|
||||
inclureCashback, setInclureCashback,
|
||||
netMode,
|
||||
showActual, toggleActual,
|
||||
showProjected, toggleProjected,
|
||||
modeGlobal, toggleModeGlobal,
|
||||
currentYear, currentMonth,
|
||||
chartInterets, chartCapital, chartCashback,
|
||||
} = useInteretsChart();
|
||||
|
||||
const [data, setData] = useState(null);
|
||||
const [libIcons, setLibIcons] = useState({});
|
||||
const [windowStart, setWindowStart] = useState(0);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
/* ── Toggle consolidation détenteurs (clé partagée avec CapitalMensuelTable) ── */
|
||||
const [groupByNom, setGroupByNom] = useState(() => {
|
||||
try { return localStorage.getItem('cl_tip_group_by_nom') === 'true'; } catch { return false; }
|
||||
});
|
||||
const toggleGroupByNom = () => {
|
||||
setGroupByNom(v => {
|
||||
const next = !v;
|
||||
try { localStorage.setItem('cl_tip_group_by_nom', String(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
/* ── Icônes bibliothèque ─────────────────────────────────────── */
|
||||
useEffect(() => {
|
||||
api.get('/icons').then(rows => {
|
||||
const m = {};
|
||||
rows.forEach(r => { m[r.name] = r.filename; });
|
||||
setLibIcons(m);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
/* ── Fenêtre années ──────────────────────────────────────────── */
|
||||
const canPrev = windowStart > 0;
|
||||
const canNext = windowStart + 3 < availableYears.length;
|
||||
const visibleYears = availableYears.length ? availableYears.slice(windowStart, windowStart + 3) : [annee];
|
||||
|
||||
useEffect(() => {
|
||||
if (!availableYears.length || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
const idx = availableYears.indexOf(annee);
|
||||
const safe = idx >= 0 ? idx : availableYears.length - 1;
|
||||
setWindowStart(Math.max(0, Math.min(availableYears.length - 3, safe - 1)));
|
||||
}, [availableYears]);
|
||||
|
||||
/* ── Réduction PFU ───────────────────────────────────────────── */
|
||||
const pfuReduction = useMemo(() => {
|
||||
if (!pfuRates?.length) return 0;
|
||||
const r = pfuRates.find(r => r.annee === annee)
|
||||
?? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]);
|
||||
return (r.prelev_sociaux + r.impot_revenu) / 100;
|
||||
}, [pfuRates, annee]);
|
||||
|
||||
/* ── Fetch données ───────────────────────────────────────────── */
|
||||
useEffect(() => {
|
||||
if (modeGlobal) { setData(null); onCapitalMensuel?.([]); return; }
|
||||
const params = { annee, ...(activeView === 'all' ? { scope: 'all' } : {}) };
|
||||
api.get('/dashboard/interets-par-plateforme', params)
|
||||
.then(d => { setData(d); onCapitalMensuel?.(d.capitalMensuel ?? []); })
|
||||
.catch(() => {});
|
||||
}, [annee, activeView, activeId, modeGlobal]);
|
||||
|
||||
/* ── Helpers affichage ───────────────────────────────────────── */
|
||||
const AppIcon = ({ name, size = 28, active = false }) => {
|
||||
const filename = libIcons[name];
|
||||
if (filename) return (
|
||||
<img src={ICONS_BASE + filename} className="app-lib-icon" width={size} height={size}
|
||||
aria-hidden="true"
|
||||
style={{ opacity: active ? 1 : 0.35, display: 'block', transition: 'opacity .15s' }} />
|
||||
);
|
||||
return <span style={{ width: size, height: size, display: 'block', borderRadius: 4,
|
||||
background: 'var(--text-muted)', opacity: active ? 0.55 : 0.2, transition: 'opacity .15s' }} />;
|
||||
};
|
||||
|
||||
const plateformes = data?.plateformes ?? [];
|
||||
const capitalMensuel = data?.capitalMensuel ?? [];
|
||||
|
||||
// N'afficher le détenteur que s'il y en a plusieurs distincts (pattern multiDetenteur)
|
||||
const multiDetenteur = new Set(plateformes.map(p => p.detenteur_nom).filter(Boolean)).size > 1;
|
||||
|
||||
/* ── Consolidation par nom si demandée ──────────────────────── */
|
||||
const displayPlateformes = useMemo(() => {
|
||||
if (!groupByNom || !multiDetenteur) return plateformes;
|
||||
const byNom = {};
|
||||
for (const plat of plateformes) {
|
||||
if (!byNom[plat.nom]) {
|
||||
byNom[plat.nom] = {
|
||||
...plat,
|
||||
id: plat.nom,
|
||||
detenteur_nom: null,
|
||||
rembourses: { ...plat.rembourses },
|
||||
projections: { ...plat.projections },
|
||||
};
|
||||
} else {
|
||||
byNom[plat.nom].rembourses = mergeMaps(byNom[plat.nom].rembourses, plat.rembourses);
|
||||
byNom[plat.nom].projections = mergeMaps(byNom[plat.nom].projections, plat.projections);
|
||||
}
|
||||
}
|
||||
return Object.values(byNom);
|
||||
}, [plateformes, groupByNom, multiDetenteur]);
|
||||
|
||||
if (modeGlobal || !data || plateformes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── Valeurs par plateforme/mois ────────────────────────────────
|
||||
* getCellValue : pour l'affichage (interets + cashback + capital selon toggles)
|
||||
* getPerfValue : pour la performance (interets + cashback uniquement, jamais capital)
|
||||
* ─────────────────────────────────────────────────────────────── */
|
||||
const buildValue = (plat, mIdx, { withCapital }) => {
|
||||
const m = mIdx + 1;
|
||||
const moisStr = `${annee}-${String(m).padStart(2, '0')}`;
|
||||
const isFuture = annee > currentYear || (annee === currentYear && m > currentMonth);
|
||||
const isCurrent = annee === currentYear && m === currentMonth;
|
||||
|
||||
if (isFuture) {
|
||||
if (!showProjected) return null;
|
||||
const proj = plat.projections[moisStr];
|
||||
if (!proj) return null;
|
||||
let v = 0;
|
||||
if (inclureInterets) v += netMode ? proj.interets_prevus * (1 - pfuReduction) : proj.interets_prevus;
|
||||
if (withCapital && inclureCapital) v += proj.capital_prevu ?? 0;
|
||||
return v > 0 ? { value: v, projected: true } : null;
|
||||
}
|
||||
|
||||
if (isCurrent) {
|
||||
const remb = plat.rembourses[moisStr];
|
||||
const proj = plat.projections[moisStr];
|
||||
let real = 0;
|
||||
if (showActual && remb) {
|
||||
if (inclureInterets) real += netMode ? remb.interets_nets : remb.interets_bruts;
|
||||
if (inclureCashback) real += remb.cashback ?? 0;
|
||||
if (withCapital && inclureCapital) real += remb.capital ?? 0;
|
||||
}
|
||||
let projAmt = 0;
|
||||
// Les projections backend sont déjà filtrées NOT EXISTS par investissement → pas de double-comptage
|
||||
if (showProjected && proj) {
|
||||
if (inclureInterets) projAmt += netMode ? proj.interets_prevus * (1 - pfuReduction) : proj.interets_prevus;
|
||||
if (withCapital && inclureCapital) projAmt += proj.capital_prevu ?? 0;
|
||||
}
|
||||
const val = real + projAmt;
|
||||
return val > 0 ? { value: val, projected: projAmt > 0 } : null;
|
||||
}
|
||||
|
||||
// Mois passé
|
||||
if (!showActual) return null;
|
||||
const remb = plat.rembourses[moisStr];
|
||||
if (!remb) return null;
|
||||
let v = 0;
|
||||
if (inclureInterets) v += netMode ? remb.interets_nets : remb.interets_bruts;
|
||||
if (inclureCashback) v += remb.cashback ?? 0;
|
||||
if (withCapital && inclureCapital) v += remb.capital ?? 0;
|
||||
return v > 0 ? { value: v, projected: false } : null;
|
||||
};
|
||||
|
||||
const getCellValue = (plat, mIdx) => buildValue(plat, mIdx, { withCapital: true });
|
||||
const getPerfValue = (plat, mIdx) => buildValue(plat, mIdx, { withCapital: false });
|
||||
|
||||
/* ── Grille ──────────────────────────────────────────────────── */
|
||||
const grid = displayPlateformes.map(plat => ({
|
||||
...plat,
|
||||
months: Array.from({ length: 12 }, (_, i) => getCellValue(plat, i)),
|
||||
}));
|
||||
|
||||
const monthTotals = Array.from({ length: 12 }, (_, i) =>
|
||||
grid.reduce((s, row) => s + (row.months[i]?.value ?? 0), 0));
|
||||
const platTotals = grid.map(row =>
|
||||
row.months.reduce((s, v) => s + (v?.value ?? 0), 0));
|
||||
const grandTotal = monthTotals.reduce((s, v) => s + v, 0);
|
||||
|
||||
/* Totaux pour la performance : intérêts + cashback uniquement (sans capital) */
|
||||
const perfMonthTotals = Array.from({ length: 12 }, (_, i) =>
|
||||
displayPlateformes.reduce((s, plat) => s + (getPerfValue(plat, i)?.value ?? 0), 0));
|
||||
const perfGrandTotal = perfMonthTotals.reduce((s, v) => s + v, 0);
|
||||
|
||||
/* ── Capital et performances ─────────────────────────────────── */
|
||||
const capitalValues = capitalMensuel.map(c => c.capital);
|
||||
const nonZeroCap = capitalValues.filter(v => v > 0);
|
||||
const avgCapital = nonZeroCap.length ? nonZeroCap.reduce((s, v) => s + v, 0) / nonZeroCap.length : 0;
|
||||
const lastCapital = [...capitalValues].reverse().find(v => v > 0) ?? avgCapital;
|
||||
|
||||
const perfMensuelle = Array.from({ length: 12 }, (_, i) =>
|
||||
capitalValues[i] > 0 ? perfMonthTotals[i] / capitalValues[i] : null);
|
||||
const perfAnnualisee = perfMensuelle.map(p => p !== null ? p * 12 : null);
|
||||
const perfAnnTotale = lastCapital > 0 ? perfGrandTotal / lastCapital : null;
|
||||
|
||||
/* ── Label total header ──────────────────────────────────────── */
|
||||
const activeTypes = [
|
||||
inclureInterets && { color: chartInterets, label: netMode ? 'Intérêts nets' : 'Intérêts bruts' },
|
||||
inclureCapital && { color: chartCapital, label: 'Capital' },
|
||||
inclureCashback && { color: chartCashback, label: 'Cashback' },
|
||||
].filter(Boolean);
|
||||
|
||||
/* ── Rendu ───────────────────────────────────────────────────── */
|
||||
return (
|
||||
<div className="solde-chart-wrap" style={{ padding: '24px 24px 16px', marginBottom: 24 }}>
|
||||
|
||||
{/* ── Header identique au bar chart ── */}
|
||||
<div className="solde-chart-header">
|
||||
<div className="solde-chart-info">
|
||||
<div style={{ display:'flex', alignItems:'center', gap:5, flexWrap:'wrap', marginBottom:2 }}>
|
||||
{inclureInterets && (
|
||||
<span style={{ display:'inline-flex', alignItems:'center', gap:4,
|
||||
background:hexToRgba(chartInterets,0.12), borderRadius:5, padding:'3px 8px' }}>
|
||||
<span style={{ width:7, height:7, borderRadius:2, background:chartInterets, flexShrink:0 }}/>
|
||||
<span style={{ fontSize:13, color:chartInterets, fontWeight:600 }}>
|
||||
{netMode ? 'Intérêts nets' : 'Intérêts bruts'}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{inclureCapital && (
|
||||
<span style={{ display:'inline-flex', alignItems:'center', gap:4,
|
||||
background:hexToRgba(chartCapital,0.12), borderRadius:5, padding:'3px 8px' }}>
|
||||
<span style={{ width:7, height:7, borderRadius:2, background:chartCapital, flexShrink:0 }}/>
|
||||
<span style={{ fontSize:13, color:chartCapital, fontWeight:600 }}>Capital</span>
|
||||
</span>
|
||||
)}
|
||||
{inclureCashback && (
|
||||
<span style={{ display:'inline-flex', alignItems:'center', gap:4,
|
||||
background:hexToRgba(chartCashback,0.12), borderRadius:5, padding:'3px 8px' }}>
|
||||
<span style={{ width:7, height:7, borderRadius:2, background:chartCashback, flexShrink:0 }}/>
|
||||
<span style={{ fontSize:13, color:chartCashback, fontWeight:600 }}>Cashback</span>
|
||||
</span>
|
||||
)}
|
||||
{!inclureInterets && !inclureCapital && !inclureCashback && (
|
||||
<span style={{ fontSize:13, color:'var(--text-muted)' }}>—</span>
|
||||
)}
|
||||
<span style={{ fontSize:13, color:'var(--text-muted)' }}>· {annee}</span>
|
||||
</div>
|
||||
<div className="solde-chart-value">{fmtEUR(grandTotal)}</div>
|
||||
</div>
|
||||
|
||||
<div className="solde-chart-controls">
|
||||
{/* Bouton intérêts */}
|
||||
<button
|
||||
title={inclureInterets ? 'Intérêts inclus — cliquer pour exclure' : 'Cliquer pour inclure les intérêts'}
|
||||
onClick={() => setInclureInterets(v => !v)}
|
||||
style={{ background: inclureInterets ? hexToRgba(chartInterets,0.13) : 'none',
|
||||
border:'1px solid '+(inclureInterets ? chartInterets : 'transparent'),
|
||||
borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center',
|
||||
transition:'background .15s,border-color .15s', marginRight:2 }}>
|
||||
<AppIcon name="interets" active={inclureInterets} />
|
||||
</button>
|
||||
{/* Bouton capital */}
|
||||
<button
|
||||
title={inclureCapital ? 'Capital inclus — cliquer pour exclure' : 'Cliquer pour inclure le capital'}
|
||||
onClick={() => setInclureCapital(v => !v)}
|
||||
style={{ background: inclureCapital ? hexToRgba(chartCapital,0.13) : 'none',
|
||||
border:'1px solid '+(inclureCapital ? chartCapital : 'transparent'),
|
||||
borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center',
|
||||
transition:'background .15s,border-color .15s', marginRight:2 }}>
|
||||
<AppIcon name="capital" active={inclureCapital} />
|
||||
</button>
|
||||
{/* Bouton cashback */}
|
||||
<button
|
||||
title={inclureCashback ? 'Cashback inclus — cliquer pour exclure' : 'Cliquer pour inclure le cashback'}
|
||||
onClick={() => setInclureCashback(v => !v)}
|
||||
style={{ background: inclureCashback ? hexToRgba(chartCashback,0.13) : 'none',
|
||||
border:'1px solid '+(inclureCashback ? chartCashback : 'transparent'),
|
||||
borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center',
|
||||
transition:'background .15s,border-color .15s', marginRight:2 }}>
|
||||
<AppIcon name="cashback" active={inclureCashback} />
|
||||
</button>
|
||||
|
||||
{/* Sélecteur d'années */}
|
||||
<div className="solde-chart-ranges">
|
||||
<button className="solde-range-btn"
|
||||
onClick={() => setWindowStart(w => Math.max(0, w-1))}
|
||||
disabled={!canPrev} style={{ opacity: canPrev ? 1 : 0.3 }}>‹</button>
|
||||
{visibleYears.map(y => (
|
||||
<button key={y} className={`solde-range-btn${annee === y ? ' active' : ''}`}
|
||||
onClick={() => setAnnee(y)}>
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
<button className="solde-range-btn"
|
||||
onClick={() => setWindowStart(w => Math.min(Math.max(0, availableYears.length - 3), w+1))}
|
||||
disabled={!canNext} style={{ opacity: canNext ? 1 : 0.3 }}>›</button>
|
||||
<button className={`solde-range-btn${modeGlobal ? ' active' : ''}`}
|
||||
onClick={() => toggleModeGlobal()}>
|
||||
TOUT
|
||||
</button>
|
||||
{expandButton}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Tableau ── */}
|
||||
<div style={{ overflowX: 'auto', marginTop: 20, position: 'relative', zIndex: 0 }}>
|
||||
<table className="tip-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="tip-th-empty" />
|
||||
<th className="tip-th-year" colSpan={12}>{annee}</th>
|
||||
<th className="tip-th-empty" />
|
||||
<th className="tip-th-empty" />
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="tip-th-name tip-th-name-amber">
|
||||
<span style={{ display:'flex', alignItems:'center', gap:6 }}>
|
||||
Plateforme
|
||||
{multiDetenteur && (
|
||||
<button
|
||||
onClick={() => toggleGroupByNom()}
|
||||
title={groupByNom ? 'Vue consolidée — cliquer pour détailler par détenteur' : 'Vue détaillée — cliquer pour consolider par plateforme'}
|
||||
style={{
|
||||
display:'inline-flex', alignItems:'center', gap:3,
|
||||
background:'rgba(255,255,255,0.15)', border:'1px solid rgba(255,255,255,0.3)',
|
||||
borderRadius:4, padding:'2px 5px', cursor:'pointer',
|
||||
fontSize:10, fontWeight:600, color:'#fff', letterSpacing:'.03em',
|
||||
lineHeight:1.4, whiteSpace:'nowrap', transition:'background .15s',
|
||||
}}>
|
||||
{groupByNom ? 'Consolidé' : 'Détaillé'}
|
||||
<span style={{ display:'inline-flex', transition:'transform .2s', transform: groupByNom ? 'rotate(180deg)' : 'rotate(0deg)' }}>
|
||||
<ChevronDown size={9} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
{MOIS_LONG.map((m, i) => (
|
||||
<th key={m} className={`tip-th-month${annee === currentYear && i === currentMonth - 1 ? ' tip-th-month-current' : ''}`}>{m}</th>
|
||||
))}
|
||||
<th className="tip-th-total">Total</th>
|
||||
<th className="tip-th-avg">Moy. mensuelle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{grid.map((plat, pi) => (
|
||||
<tr key={plat.id} className="tip-row-plat">
|
||||
<td className="tip-td-name">
|
||||
{plat.nom}
|
||||
{!groupByNom && multiDetenteur && plat.detenteur_nom && (
|
||||
<span style={{ marginLeft: 6, fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>
|
||||
{plat.detenteur_nom}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{plat.months.map((v, mi) => {
|
||||
const isCurrent = annee === currentYear && mi === currentMonth - 1;
|
||||
const cellKey = `${plat.id}:${annee}-${String(mi + 1).padStart(2,'0')}`;
|
||||
const isActive = activeCell?.key === cellKey;
|
||||
const clickable = !!v;
|
||||
return (
|
||||
<td key={mi}
|
||||
className={`tip-td-num${v?.projected ? ' tip-projected' : ''}${isCurrent ? ' tip-col-current' : ''}${isActive ? ' tip-td-active' : ''}${clickable ? ' tip-td-clickable' : ''}`}
|
||||
onClick={() => clickable && onCellClick && onCellClick({
|
||||
key: cellKey,
|
||||
platId: plat.id,
|
||||
platNom: plat.nom,
|
||||
annee,
|
||||
mois: String(mi + 1).padStart(2, '0'),
|
||||
moisLabel: MOIS_LONG[mi],
|
||||
})}
|
||||
>
|
||||
{v ? fmtEUR(v.value) : <span className="tip-dash">—</span>}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="tip-td-total">
|
||||
{platTotals[pi] > 0 ? fmtEUR(platTotals[pi]) : <span className="tip-dash">—</span>}
|
||||
</td>
|
||||
<td className="tip-td-avg">
|
||||
{platTotals[pi] > 0 ? fmtEUR(platTotals[pi] / 12) : <span className="tip-dash">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
<tfoot>
|
||||
<tr className="tip-footer-total">
|
||||
<td className="tip-td-name">Toutes les plateformes</td>
|
||||
{monthTotals.map((v, i) => (
|
||||
<td key={i} className={`tip-td-num${annee === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`}>
|
||||
{v > 0 ? fmtEUR(v) : <span className="tip-dash">—</span>}
|
||||
</td>
|
||||
))}
|
||||
<td className="tip-td-total">{fmtEUR(grandTotal)}</td>
|
||||
<td className="tip-td-avg">{grandTotal > 0 ? fmtEUR(grandTotal / 12) : <span className="tip-dash">—</span>}</td>
|
||||
</tr>
|
||||
<tr className="tip-footer-capital">
|
||||
<td className="tip-td-name">Capital investi</td>
|
||||
{capitalValues.map((v, i) => (
|
||||
<td key={i} className={`tip-td-num${annee === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`}>
|
||||
{v > 0 ? fmtEUR(v) : <span className="tip-dash">—</span>}
|
||||
</td>
|
||||
))}
|
||||
<td className="tip-td-total">{lastCapital > 0 ? fmtEUR(lastCapital) : <span className="tip-dash">—</span>}</td>
|
||||
<td className="tip-td-void" />
|
||||
</tr>
|
||||
<tr className="tip-footer-perf">
|
||||
<td className="tip-td-name">{netMode ? "Performance nette mensuelle" : "Performance brute mensuelle"}</td>
|
||||
{perfMensuelle.map((v, i) => (
|
||||
<td key={i} className={`tip-td-num${annee === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`}>
|
||||
{v !== null ? fmtPct(v * 100) : <span className="tip-dash">—</span>}
|
||||
</td>
|
||||
))}
|
||||
<td className="tip-td-total">
|
||||
{perfAnnTotale !== null ? fmtPct((perfAnnTotale / 12) * 100) : <span className="tip-dash">—</span>}
|
||||
</td>
|
||||
<td className="tip-td-void" />
|
||||
</tr>
|
||||
<tr className="tip-footer-perf">
|
||||
<td className="tip-td-name">{netMode ? "Performance nette annualisée" : "Performance brute annualisée"}</td>
|
||||
{perfAnnualisee.map((v, i) => (
|
||||
<td key={i} className={`tip-td-num${annee === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`}>
|
||||
{v !== null ? fmtPct(v * 100) : <span className="tip-dash">—</span>}
|
||||
</td>
|
||||
))}
|
||||
<td className="tip-td-total">
|
||||
{perfAnnTotale !== null ? fmtPct(perfAnnTotale * 100) : <span className="tip-dash">—</span>}
|
||||
</td>
|
||||
<td className="tip-td-void" />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* ── Sélecteur Reçu / Projeté ── */}
|
||||
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginTop:12, gap:12, flexWrap:'wrap' }}>
|
||||
<div style={{
|
||||
display:'inline-flex',
|
||||
background:'#f0f0f0',
|
||||
borderRadius:8,
|
||||
padding:3,
|
||||
gap:2,
|
||||
flexShrink:0,
|
||||
}}>
|
||||
{[
|
||||
{ key:'actual', label:'Reçu', active:showActual, toggle:toggleActual },
|
||||
{ key:'projected', label:'Projeté', active:showProjected, toggle:toggleProjected },
|
||||
].map(btn => (
|
||||
<button key={btn.key} onClick={() => btn.toggle()} style={{
|
||||
border:'none', cursor:'pointer', padding:'5px 14px', borderRadius:6,
|
||||
fontSize:'var(--fs-sm)',
|
||||
fontWeight: btn.active ? 600 : 400,
|
||||
background: btn.active ? '#ffffff' : 'transparent',
|
||||
color: btn.active ? '#1a1a2e' : '#9ca3af',
|
||||
boxShadow: btn.active ? '0 1px 3px rgba(0,0,0,0.13)' : 'none',
|
||||
transition: 'all .15s', lineHeight: 1.4,
|
||||
}}>{btn.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useTheme } from '../context/ThemeContext.jsx';
|
||||
|
||||
const OPTIONS = [
|
||||
{ mode: 'light', icon: '☀', label: 'Clair' },
|
||||
{ mode: 'dark', icon: '☾', label: 'Sombre' },
|
||||
{ mode: 'system', icon: '◐', label: 'Système' },
|
||||
];
|
||||
|
||||
export default function ThemeSwitcher() {
|
||||
const { mode, setMode } = useTheme();
|
||||
return (
|
||||
<div className="theme-switcher" role="group" aria-label="Thème">
|
||||
{OPTIONS.map(o => (
|
||||
<button
|
||||
key={o.mode}
|
||||
type="button"
|
||||
className={mode === o.mode ? 'active' : ''}
|
||||
onClick={() => setMode(o.mode)}
|
||||
title={o.label}
|
||||
aria-pressed={mode === o.mode}
|
||||
>
|
||||
<span aria-hidden="true">{o.icon}</span>
|
||||
<span>{o.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext.jsx';
|
||||
import { useUi } from '../context/UiContext.jsx';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
import { memberInitials, memberLabel } from '../utils/format.js';
|
||||
|
||||
/* ── Icons ───────────────────────────────────────────────────── */
|
||||
function IconUser() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>;
|
||||
}
|
||||
function IconLogout() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>;
|
||||
}
|
||||
function IconAdmin() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="9" cy="6" r="3"/><path d="M2 16c0-3.3 3.1-6 7-6"/><path d="M14 13l-1.5 1.5L14 16"/><circle cx="15.5" cy="14.5" r="2.5"/></svg>;
|
||||
}
|
||||
function IconSettings() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><line x1="4" y1="6" x2="20" y2="6"/><circle cx="8" cy="6" r="2" fill="var(--user-menu-bg, #1e2d4a)"/><line x1="4" y1="12" x2="20" y2="12"/><circle cx="16" cy="12" r="2" fill="var(--user-menu-bg, #1e2d4a)"/><line x1="4" y1="18" x2="20" y2="18"/><circle cx="10" cy="18" r="2" fill="var(--user-menu-bg, #1e2d4a)"/></svg>;
|
||||
}
|
||||
function IconAide() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>;
|
||||
}
|
||||
function IconChevronRight() {
|
||||
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M9 18l6-6-6-6"/></svg>;
|
||||
}
|
||||
function IconChevronUp({ open }) {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ transition: 'transform .2s', transform: open ? 'rotate(0deg)' : 'rotate(180deg)', flexShrink: 0 }}
|
||||
aria-hidden="true">
|
||||
<path d="M18 15l-6-6-6 6"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function IconCheck() {
|
||||
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>;
|
||||
}
|
||||
function IconTeam() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>;
|
||||
}
|
||||
|
||||
/* ── Avatars ─────────────────────────────────────────────────── */
|
||||
function UserAvatar({ user, size = 36 }) {
|
||||
const initials = user?.display_name
|
||||
? user.display_name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
|
||||
: (user?.email || '?')[0].toUpperCase();
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #1e40af 0%, #1e3a8a 100%)',
|
||||
border: '2px solid rgba(74,222,128,.5)',
|
||||
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontWeight: 700, fontSize: Math.round(size * 0.38), flexShrink: 0,
|
||||
letterSpacing: '.02em', userSelect: 'none',
|
||||
}}>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberAvatar({ member, size = 28 }) {
|
||||
const isEntreprise = member?.type === 'entreprise';
|
||||
const bg = isEntreprise
|
||||
? 'linear-gradient(135deg, #3730a3, #4338ca)'
|
||||
: 'linear-gradient(135deg, #1e3a8a, #1e40af)';
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: bg, color: '#fff',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontWeight: 700, fontSize: Math.round(size * 0.38), flexShrink: 0,
|
||||
letterSpacing: '.02em', userSelect: 'none',
|
||||
}}>
|
||||
{memberInitials(member)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamBadge({ size = 28 }) {
|
||||
return (
|
||||
<div className="team-badge" style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: '#1e3a8a',
|
||||
border: '1.5px solid rgba(255,255,255,.25)',
|
||||
color: '#fff',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<IconTeam />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Composant principal ─────────────────────────────────────── */
|
||||
export default function UserMenu() {
|
||||
const { user, logout, isAdmin } = useAuth();
|
||||
const { sidebarCollapsed } = useUi();
|
||||
const { investisseurs, activeView, activeViewMember, setActiveView } = useInvestisseur();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [subOpen, setSubOpen] = useState(false);
|
||||
const [popupStyle, setPopupStyle] = useState({});
|
||||
const [subStyle, setSubStyle] = useState({});
|
||||
|
||||
const triggerRef = useRef(null);
|
||||
const popupRef = useRef(null);
|
||||
const subRef = useRef(null);
|
||||
const closeTimer = useRef(null);
|
||||
|
||||
const famille = investisseurs.filter(i => i.type !== 'entreprise');
|
||||
const entreprises = investisseurs.filter(i => i.type === 'entreprise');
|
||||
|
||||
const POPUP_W = 250;
|
||||
const SUB_W = 230;
|
||||
|
||||
/* ── Calcul position (fixed = échappe overflow:hidden) ────── */
|
||||
const computePosition = useCallback(() => {
|
||||
if (!triggerRef.current) return;
|
||||
const r = triggerRef.current.getBoundingClientRect();
|
||||
|
||||
let mainLeft, mainBottom, mainWidth;
|
||||
if (sidebarCollapsed) {
|
||||
mainLeft = r.right + 8;
|
||||
mainBottom = window.innerHeight - r.bottom;
|
||||
mainWidth = POPUP_W;
|
||||
} else {
|
||||
mainLeft = r.left;
|
||||
mainBottom = window.innerHeight - r.top + 6;
|
||||
mainWidth = r.width;
|
||||
}
|
||||
|
||||
setPopupStyle({
|
||||
position: 'fixed', left: mainLeft, bottom: mainBottom,
|
||||
width: mainWidth, top: 'auto', right: 'auto',
|
||||
});
|
||||
setSubStyle({
|
||||
position: 'fixed',
|
||||
left: mainLeft + (sidebarCollapsed ? POPUP_W : mainWidth) + 6,
|
||||
bottom: mainBottom,
|
||||
width: SUB_W, top: 'auto', right: 'auto',
|
||||
});
|
||||
}, [sidebarCollapsed]);
|
||||
|
||||
const openMenu = useCallback(() => { computePosition(); setOpen(true); }, [computePosition]);
|
||||
const closeMenu = useCallback(() => { setOpen(false); setSubOpen(false); }, []);
|
||||
|
||||
const clearClose = () => clearTimeout(closeTimer.current);
|
||||
const scheduleClose = () => { closeTimer.current = setTimeout(closeMenu, 200); };
|
||||
|
||||
/* Fermeture clic extérieur */
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e) => {
|
||||
const inTrigger = triggerRef.current?.contains(e.target);
|
||||
const inPopup = popupRef.current?.contains(e.target);
|
||||
const inSub = subRef.current?.contains(e.target);
|
||||
if (!inTrigger && !inPopup && !inSub) closeMenu();
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
return () => document.removeEventListener('mousedown', onDown);
|
||||
}, [open, closeMenu]);
|
||||
|
||||
/* Recalcul si sidebar change */
|
||||
useEffect(() => { if (open) computePosition(); }, [sidebarCollapsed, open, computePosition]);
|
||||
|
||||
/* Handlers ouverture */
|
||||
const onWrapEnter = () => { if (sidebarCollapsed) { clearClose(); openMenu(); } };
|
||||
const onWrapLeave = () => { if (sidebarCollapsed) scheduleClose(); };
|
||||
const onTriggerClick = () => { if (!sidebarCollapsed) { open ? closeMenu() : openMenu(); } };
|
||||
|
||||
const go = (path) => { closeMenu(); navigate(path); };
|
||||
const handleLogout = () => { closeMenu(); logout(); navigate('/login'); };
|
||||
|
||||
const selectView = (v) => {
|
||||
setActiveView(v);
|
||||
closeMenu();
|
||||
};
|
||||
|
||||
/* ── Libellés ────────────────────────────────────────────── */
|
||||
const viewLabel = activeView === 'all'
|
||||
? 'Famille et entreprises'
|
||||
: (activeViewMember ? memberLabel(activeViewMember) : 'Famille et entreprises');
|
||||
|
||||
const TriggerBadge = activeView === 'all'
|
||||
? <TeamBadge size={30} />
|
||||
: <MemberAvatar member={activeViewMember} size={30} />;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="user-menu-wrap"
|
||||
onMouseEnter={onWrapEnter}
|
||||
onMouseLeave={onWrapLeave}
|
||||
>
|
||||
{/* ── Main Popup (portail → échappe tout stacking context) ── */}
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={popupRef}
|
||||
id="user-menu-popup"
|
||||
className="user-menu-popup"
|
||||
style={popupStyle}
|
||||
role="menu"
|
||||
onMouseEnter={clearClose}
|
||||
onMouseLeave={() => { if (sidebarCollapsed) scheduleClose(); }}
|
||||
>
|
||||
{/* En-tête compte utilisateur */}
|
||||
<div className="user-menu-header">
|
||||
<UserAvatar user={user} size={40} />
|
||||
<div style={{ overflow: 'hidden', flex: 1 }}>
|
||||
{user?.display_name && <div className="user-menu-name">{user.display_name}</div>}
|
||||
<div className="user-menu-email">{user?.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-menu-sep" />
|
||||
|
||||
{/* Section Vue du profil */}
|
||||
<div className="user-menu-section-header">Vue du profil</div>
|
||||
<button
|
||||
className={`user-menu-item user-menu-vue-row${subOpen ? ' active' : ''}`}
|
||||
role="menuitem"
|
||||
onClick={() => setSubOpen(s => !s)}
|
||||
>
|
||||
{activeView === 'all'
|
||||
? <TeamBadge size={24} />
|
||||
: <MemberAvatar member={activeViewMember} size={24} />
|
||||
}
|
||||
<span className="user-menu-vue-name">{viewLabel}</span>
|
||||
<span style={{ color: '#4a6490', display: 'flex' }}><IconChevronRight /></span>
|
||||
</button>
|
||||
|
||||
<div className="user-menu-sep" />
|
||||
|
||||
<button className="user-menu-item" role="menuitem" onClick={() => go('/compte')}>
|
||||
<IconUser /> Mon compte
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button className="user-menu-item" role="menuitem" onClick={() => go('/admin')}>
|
||||
<IconAdmin /> Administration
|
||||
</button>
|
||||
)}
|
||||
<button className="user-menu-item" role="menuitem" onClick={() => go('/settings')}>
|
||||
<IconSettings /> Paramètres
|
||||
</button>
|
||||
<button className="user-menu-item" role="menuitem" onClick={() => go('/aide')}>
|
||||
<IconAide /> Aide
|
||||
</button>
|
||||
|
||||
<div className="user-menu-sep" />
|
||||
|
||||
<button className="user-menu-item danger" role="menuitem" onClick={handleLogout}>
|
||||
<IconLogout /> Se déconnecter
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* ── Sub-panel Vue du profil (portail) ───────────────────── */}
|
||||
{open && subOpen && createPortal(
|
||||
<div
|
||||
ref={subRef}
|
||||
className="user-menu-subpanel"
|
||||
style={subStyle}
|
||||
onMouseEnter={clearClose}
|
||||
onMouseLeave={() => { if (sidebarCollapsed) scheduleClose(); }}
|
||||
>
|
||||
<div className="user-menu-subpanel-title">Vue du profil</div>
|
||||
|
||||
{/* Famille et entreprises (vue agrégée) */}
|
||||
<button
|
||||
className={`user-menu-profile-item${activeView === 'all' ? ' selected' : ''}`}
|
||||
onClick={() => selectView('all')}
|
||||
>
|
||||
<TeamBadge size={26} />
|
||||
<span className="user-menu-profile-name">Famille et entreprises</span>
|
||||
{activeView === 'all' && <IconCheck />}
|
||||
</button>
|
||||
|
||||
{/* Membres famille */}
|
||||
{famille.length > 0 && (
|
||||
<>
|
||||
<div className="user-menu-subpanel-section">
|
||||
{famille.length > 1 ? 'Profils' : 'Profil'}
|
||||
</div>
|
||||
{famille.map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
className={`user-menu-profile-item${String(activeView) === String(m.id) ? ' selected' : ''}`}
|
||||
onClick={() => selectView(String(m.id))}
|
||||
>
|
||||
<MemberAvatar member={m} size={26} />
|
||||
<span className="user-menu-profile-name">{memberLabel(m)}</span>
|
||||
{String(activeView) === String(m.id) && <IconCheck />}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Entreprises */}
|
||||
{entreprises.length > 0 && (
|
||||
<>
|
||||
<div className="user-menu-subpanel-section">
|
||||
{entreprises.length > 1 ? 'Entreprises' : 'Entreprise'}
|
||||
</div>
|
||||
{entreprises.map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
className={`user-menu-profile-item${String(activeView) === String(m.id) ? ' selected' : ''}`}
|
||||
onClick={() => selectView(String(m.id))}
|
||||
>
|
||||
<MemberAvatar member={m} size={26} />
|
||||
<span className="user-menu-profile-name">{memberLabel(m)}</span>
|
||||
{String(activeView) === String(m.id) && <IconCheck />}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="user-menu-sep" style={{ margin: '6px 0' }} />
|
||||
|
||||
<button
|
||||
className="user-menu-profile-manage"
|
||||
onClick={() => go('/compte?section=famille')}
|
||||
>
|
||||
Gérer les profils
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* ── Déclencheur (bas de sidebar) ─────────────────────── */}
|
||||
<button
|
||||
ref={triggerRef}
|
||||
className="user-menu-trigger"
|
||||
onClick={onTriggerClick}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={viewLabel}
|
||||
>
|
||||
{TriggerBadge}
|
||||
{!sidebarCollapsed && (
|
||||
<div className="user-menu-trigger-info">
|
||||
<span className="user-menu-trigger-name">{viewLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
{!sidebarCollapsed && <IconChevronUp open={open} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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
@@ -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 />
|
||||
− Retraits manuels<br />
|
||||
+ Remboursements crédités au porte-monnaie<br />
|
||||
+ Bonus (parrainage / plateforme)<br />
|
||||
− Capital investi (en cours ou remboursé)<br />
|
||||
+ 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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
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
@@ -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();
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:4000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user