Initial commit

This commit is contained in:
Olivier CROGUENNEC
2026-06-13 14:57:15 +02:00
commit 48ed7fe65e
209 changed files with 49979 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
import { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';
import UsersSection from './admin/UsersSection.jsx';
import CreateUserSection from './admin/CreateUserSection.jsx';
import JobLogsSection from './admin/JobLogsSection.jsx';
import IconsSection from './admin/IconsSection.jsx';
/* ── Icônes nav ───────────────────────────────────────────────── */
function IconUsers() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>; }
function IconUserPlus() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>; }
function IconActivity() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>; }
function IconImage() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>; }
function IconTax() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>; }
function IconDatabase() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>; }
const NAV = [
{
group: 'Administration de la plateforme',
items: [
{ id: 'users', label: 'Utilisateurs', icon: <IconUsers /> },
{ id: 'create', label: 'Créer un utilisateur', icon: <IconUserPlus /> },
{ id: 'job-logs', label: 'Logs des jobs', icon: <IconActivity /> },
{ id: 'icons', label: "Bibliothèque d'icônes", icon: <IconImage /> },
],
},
{
group: 'Référentiels',
items: [
{ id: 'plateformes', label: 'Plateformes', icon: <IconDatabase />, href: '/admin/plateformes' },
{ id: 'fiscalite', label: 'Fiscalité', icon: <IconTax />, href: '/admin/fiscalite' },
],
},
];
export default function Admin() {
const { search } = useLocation();
const navigate = useNavigate();
const [refreshKey, setRefreshKey] = useState(0);
const { user } = useAuth();
const section = new URLSearchParams(search).get('section') || 'users';
if (!user) return null;
return (
<div className="account-layout">
<aside className="account-sidebar">
<h1 className="account-title">Administration</h1>
{NAV.map(group => (
<div key={group.group} className="account-nav-group">
<span className="account-nav-label">{group.group}</span>
{group.items.map(item => (
<button
key={item.id}
className={`account-nav-item${section === item.id ? ' active' : ''}`}
onClick={() => item.href ? navigate(item.href) : navigate(`/admin?section=${item.id}`, { replace: true })}
>
{item.icon}
{item.label}
</button>
))}
</div>
))}
</aside>
<div className="account-content">
{section === 'users' && <UsersSection currentUserId={user?.id} key={refreshKey} />}
{section === 'create' && <CreateUserSection onCreated={() => setRefreshKey(k => k + 1)} />}
{section === 'job-logs' && <JobLogsSection />}
{section === 'icons' && <IconsSection />}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+188
View File
@@ -0,0 +1,188 @@
import { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
/* ── Accordéon FAQ ───────────────────────────────────────────── */
function FaqItem({ question, children }) {
const [open, setOpen] = useState(false);
return (
<div style={{
borderBottom: '1px solid var(--border)',
padding: '0',
}}>
<button
onClick={() => setOpen(o => !o)}
style={{
width: '100%', textAlign: 'left', background: 'none', border: 'none',
padding: '14px 0', cursor: 'pointer', display: 'flex',
alignItems: 'center', justifyContent: 'space-between', gap: 12,
color: 'var(--text)', fontSize: 'var(--fs-base)', fontWeight: 500,
}}
>
<span>{question}</span>
<svg
width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
style={{ flexShrink: 0, transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s' }}
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{open && (
<div style={{
paddingBottom: 16, color: 'var(--text-muted)',
fontSize: 'var(--fs-sm)', lineHeight: 1.7,
}}>
{children}
</div>
)}
</div>
);
}
/* ── Navigation ─────────────────────────────────────────────── */
const NAV = [
{
id: 'faq',
label: 'FAQ',
icon: (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
),
},
];
/* ── Page principale ─────────────────────────────────────────── */
export default function Aide() {
const { search } = useLocation();
const navigate = useNavigate();
const section = new URLSearchParams(search).get('section') || 'faq';
const setSection = (s) => navigate(`/aide?section=${s}`, { replace: true });
return (
<div className="account-layout">
{/* ── Nav gauche ───────────────────────────────────────── */}
<aside className="account-sidebar">
<h1 className="account-title">Centre d'aide</h1>
{NAV.map(item => (
<button
key={item.id}
className={`account-nav-item${section === item.id ? ' active' : ''}`}
onClick={() => setSection(item.id)}
>
{item.icon}
{item.label}
</button>
))}
</aside>
{/* ── Contenu ──────────────────────────────────────────── */}
<div className="account-content">
{section === 'faq' && (
<div>
<h2 style={{ marginTop: 0, marginBottom: 24 }}>Questions fréquentes</h2>
<FaqItem question="Comment est calculé le solde du porte-monnaie d'une plateforme ?">
<p style={{ marginTop: 0 }}>
Le solde du porte-monnaie représente les liquidités disponibles sur une plateforme,
c'est-à-dire l'argent que vous pouvez retirer ou réinvestir. Il est calculé comme suit :
</p>
<div style={{ margin: '12px 0', padding: '12px 16px', background: 'var(--surface-2)', borderRadius: 8, fontFamily: 'monospace', fontSize: 'var(--fs-sm)', color: 'var(--text)', lineHeight: 2 }}>
Solde = Dépôts<br />
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; Retraits manuels<br />
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;+ Remboursements crédités au porte-monnaie<br />
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;+ Bonus (parrainage / plateforme)<br />
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; Capital investi (en cours ou remboursé)<br />
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;+ Corrections de solde
</div>
<p><strong style={{ color: 'var(--text)' }}>Retraits manuels</strong> — seuls les retraits que vous avez saisis manuellement sont déduits.
Les retraits générés automatiquement lors d'un remboursement en mode "compte courant" sont exclus,
car ils ne représentent pas un vrai mouvement de porte-monnaie.</p>
<p><strong style={{ color: 'var(--text)' }}>Remboursements crédités au porte-monnaie</strong> — uniquement les remboursements
dont le mode est "Portefeuille" (et non "Compte courant"). Le montant crédité dépend de la fiscalité
de la plateforme :</p>
<ul style={{ margin: '8px 0 8px 16px', paddingLeft: 0 }}>
<li style={{ marginBottom: 6 }}>
<strong style={{ color: 'var(--text)' }}>Plateforme française (Flat Tax)</strong> — le porte-monnaie
reçoit le <em>net reçu</em>, c'est-à-dire le montant après déduction du PFU français (17,2 % de prélèvements
sociaux + 12,8 % d'impôt sur le revenu), prélevé directement à la source par la plateforme.
</li>
<li style={{ marginBottom: 6 }}>
<strong style={{ color: 'var(--text)' }}>Plateforme hors France (sans fiscalité locale)</strong> — le porte-monnaie
reçoit le capital remboursé + cashback + intérêts bruts. Le PFU français n'est pas prélevé à la
source : vous devez le déclarer séparément dans votre déclaration fiscale annuelle.
</li>
<li>
<strong style={{ color: 'var(--text)' }}>Plateforme hors France (avec retenue à la source locale)</strong> — même
principe que ci-dessus, mais la plateforme a déjà prélevé une taxe locale sur les intérêts. Le
porte-monnaie reçoit le capital + cashback + intérêts bruts <em>après</em> cette retenue locale.
Le PFU français reste à déclarer séparément.
</li>
</ul>
<p><strong style={{ color: 'var(--text)' }}>Capital investi</strong> — le montant que vous avez placé dans des prêts actifs
(y compris les réinvestissements complémentaires) est soustrait du porte-monnaie, car ces fonds ne sont
plus disponibles. Ils reviennent progressivement via les remboursements.</p>
<p style={{ marginBottom: 0 }}><strong style={{ color: 'var(--text)' }}>Corrections de solde</strong> — ajustements manuels
permettant de réconcilier de micro-écarts de calcul (par exemple un arrondi de centimes sur la fiscalité).</p>
</FaqItem>
<FaqItem question="Comment mettre en place un réinvestissement automatique des intérêts ?">
<p style={{ marginTop: 0 }}>
Le réinvestissement automatique permet de capitaliser les intérêts perçus après chaque remboursement,
sans aucune saisie manuelle. Les intérêts sont automatiquement réinjectés dans le capital du prêt,
ce qui augmente progressivement le montant investi et les intérêts futurs.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Activation</h4>
<ol style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li>Ouvrez la fiche d'un investissement.</li>
<li>Cliquez sur le bouton <strong style={{ color: 'var(--text)' }}>⋮</strong> en haut à droite du bloc <em>Informations du projet</em>, puis choisissez <strong style={{ color: 'var(--text)' }}>Réinvestir</strong>.</li>
<li>Dans la modale, sélectionnez l'onglet <strong style={{ color: 'var(--text)' }}>Automatique</strong>.</li>
<li>Cliquez sur <strong style={{ color: 'var(--text)' }}>Activer</strong>.</li>
</ol>
<p>
Une fois activé, le bloc <em>Réinvestissements complémentaires</em> apparaît sur la fiche avec
un badge <strong style={{ color: 'var(--primary)' }}>auto</strong>, même si aucun remboursement
n'a encore eu lieu.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Quel montant est réinvesti ?</h4>
<p>Le montant réinvesti après chaque remboursement dépend de la fiscalité de la plateforme :</p>
<ul style={{ margin: '8px 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li>
<strong style={{ color: 'var(--text)' }}>Plateforme française (Flat Tax)</strong> — les <em>intérêts nets</em> sont réinvestis
(après déduction du PFU prélevé à la source). C'est le montant réellement reçu sur votre porte-monnaie.
</li>
<li>
<strong style={{ color: 'var(--text)' }}>Plateforme hors France</strong> les <em>intérêts bruts</em> sont réinvestis,
car aucune retenue n'est effectuée à la source. Pensez à provisionner la fiscalité due lors de votre déclaration annuelle.
</li>
</ul>
<p>Si les intérêts d'un remboursement sont nuls (remboursement de capital seul), aucun réinvestissement n'est créé.</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Désactivation</h4>
<p style={{ marginBottom: 0 }}>
Pour désactiver le réinvestissement automatique, cliquez sur <strong style={{ color: 'var(--text)' }}>⋮</strong> dans le bloc
<em> Informations du projet</em> et choisissez <strong style={{ color: 'var(--text)' }}>Désactiver le réinvestissement auto</strong>.
Les réinvestissements déjà créés sont conservés ; les prochains remboursements n'en génèreront plus.
</p>
</FaqItem>
</div>
)}
</div>
</div>
);
}
+558
View File
@@ -0,0 +1,558 @@
import { useEffect, useState, useRef, useMemo } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import PageIcon from '../components/PageIcon.jsx';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useUi } from '../context/UiContext.jsx';
import { fmtEUR, fmtPct, fmtDate, fmtStatut, memberLabel } from '../utils/format.js';
import InteretsMensuelsChart from '../components/InteretsMensuelsChart.jsx';
import InteretsDonutChart from '../components/InteretsDonutChart.jsx';
import { InteretsChartProvider, useInteretsChart } from '../context/InteretsChartContext.jsx';
import TableauInteretsPlateforme from '../components/TableauInteretsPlateforme.jsx';
import DrillCellPanel from '../components/DrillCellPanel.jsx';
/* ── Sélecteur d'année — doit être enfant de InteretsChartProvider ── */
function YearSelectorKpi() {
const { annee, setAnnee, availableYears, modeGlobal, toggleModeGlobal } = useInteretsChart();
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const handler = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const handleSelect = (value) => {
if (value === 'all') {
if (!modeGlobal) toggleModeGlobal();
} else {
if (modeGlobal) toggleModeGlobal();
setAnnee(value);
}
setOpen(false);
};
// Années récentes en premier (desc), puis "Depuis le début" en tête
const options = [
{ value: 'all', label: 'Depuis le début' },
...availableYears.map(y => ({ value: y, label: String(y) })),
];
return (
<div ref={ref} style={{ position: 'relative', flexShrink: 0, width: 200 }}>
<div
onClick={() => setOpen(v => !v)}
style={{
height: '100%', boxSizing: 'border-box',
background: 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)',
borderRadius: 10,
padding: '16px 20px',
boxShadow: open
? '0 6px 28px rgba(109,40,217,0.45)'
: '0 4px 20px rgba(109,40,217,0.30)',
cursor: 'pointer',
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
userSelect: 'none',
transition: 'box-shadow .15s',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{
fontSize: 'var(--fs-xs)', textTransform: 'uppercase',
letterSpacing: '.06em', color: 'rgba(255,255,255,0.7)', fontWeight: 500,
}}>
Période
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="rgba(255,255,255,0.7)" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<div style={{
color: '#fff',
fontSize: modeGlobal ? '1.1rem' : '2rem',
fontWeight: 700,
lineHeight: 1.1,
marginTop: 8,
}}>
{modeGlobal ? 'Depuis le début' : String(annee)}
</div>
</div>
{open && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 200,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 10, boxShadow: '0 8px 28px rgba(0,0,0,0.15)',
minWidth: 200, overflow: 'hidden',
}}>
{options.map((opt, i) => {
const isActive = opt.value === 'all' ? modeGlobal : (!modeGlobal && annee === opt.value);
return (
<div
key={opt.value}
onClick={() => handleSelect(opt.value)}
style={{
padding: '10px 16px',
cursor: 'pointer',
background: isActive ? 'rgba(109,40,217,0.08)' : 'transparent',
color: isActive ? '#7c3aed' : 'var(--text)',
fontWeight: isActive ? 700 : 400,
fontSize: 'var(--fs-sm)',
borderBottom: i < options.length - 1 ? '1px solid var(--border)' : 'none',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
transition: 'background .1s',
}}
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--surface-2)'; }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
>
<span>{opt.label}</span>
{isActive && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="#7c3aed" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
</div>
);
})}
</div>
)}
</div>
);
}
/* ── Badge de tendance ── */
function TrendBadge({ current, prev, invert = false }) {
if (prev == null || prev === 0) return null;
const diff = current - prev;
const pct = (diff / prev) * 100;
const up = diff > 0;
const neutral = diff === 0;
// invert=true : une hausse est mauvaise (ex. capital en risque)
const good = neutral ? null : (invert ? !up : up);
const color = neutral ? 'var(--text-muted)' : good ? '#16a34a' : '#dc2626';
const bg = neutral ? 'var(--surface-2)' : good ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)';
const arrow = neutral ? '→' : up ? '↗' : '↘';
const label = `${up ? '+' : ''}${Math.abs(pct) < 10 ? pct.toFixed(1) : Math.round(pct)}%`;
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '2px 8px', borderRadius: 20,
background: bg, color, fontSize: '0.76em', fontWeight: 600,
whiteSpace: 'nowrap',
}}>
{arrow} {label}
</span>
);
}
/* ── Carte KPI individuelle ── */
function KpiCard({ title, value, badge, refValue, onClick }) {
return (
<div
className="kpi"
onClick={onClick}
style={onClick ? { cursor: 'pointer', transition: 'box-shadow 0.15s, opacity 0.15s' } : undefined}
onMouseEnter={onClick ? (e) => { e.currentTarget.style.boxShadow = '0 0 0 2px var(--primary)'; e.currentTarget.style.opacity = '0.88'; } : undefined}
onMouseLeave={onClick ? (e) => { e.currentTarget.style.boxShadow = ''; e.currentTarget.style.opacity = ''; } : undefined}
>
<div className="label">{title}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{value}</span>
{badge}
</div>
{refValue && (
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{refValue}</div>
)}
</div>
);
}
/* ── KPI filtrés par année ── */
function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData }) {
const {
annee, modeGlobal, rawDataGlobal, rawData, currentYear, currentMonth,
setInclureInterets, setInclureCapital, setInclureCashback,
} = useInteretsChart();
const isCurrentYear = !modeGlobal && Number(annee) === currentYear;
const isFutureYear = !modeGlobal && Number(annee) > currentYear;
// Capital de référence pour l'année sélectionnée
// Pour les années passées : dernier mois de capitalMensuel (= capital déployé fin décembre)
// Pour l'année courante / mode global : capital actuellement déployé (portfolio)
const capitalAnnee = useMemo(() => {
if (modeGlobal || isCurrentYear) return portfolio.encours + portfolio.en_defaut;
// Année passée : prendre la valeur de fin décembre depuis capitalMensuel
const anneeStr = String(annee);
const moisAnnee = (capitalMensuelData ?? []).filter(c => c.mois?.startsWith(anneeStr));
if (moisAnnee.length > 0) {
// Prendre le dernier mois disponible (normalement décembre)
const last = moisAnnee[moisAnnee.length - 1];
return last.capital ?? 0;
}
return portfolio.encours + portfolio.en_defaut;
}, [modeGlobal, isCurrentYear, annee, capitalMensuelData, portfolio]);
// Capital souscrit par année (pour référence performance N-1, données manquantes, etc.)
const capitalParAnneeMap = useMemo(() => {
const list = rawDataGlobal.capitalParAnnee ?? [];
return Object.fromEntries(list.map(r => [r.annee, r.capital_souscrit]));
}, [rawDataGlobal]);
// ── Estimation réduction PFU pour une année donnée ──
const getPfuReduction = (yr) => {
if (!pfuRates.length) return 0;
const rate = pfuRates.find(r => r.annee === yr)
?? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]);
return (rate.prelev_sociaux + rate.impot_revenu) / 100;
};
// ── Données consolidées pour une année (actuel + projeté selon le cas) ──
const getYearData = (yr) => {
const remRow = (rawDataGlobal.rembourses ?? []).find(r => Number(r.annee) === yr);
const projRow = (rawDataGlobal.projections ?? []).find(r => Number(r.annee) === yr);
if (yr > currentYear) {
// Année future : projections uniquement
const bruts = projRow?.interets_prevus || 0;
const red = getPfuReduction(yr);
return { interets_bruts: bruts, interets_nets: bruts * (1 - red), capital: projRow?.capital_prevu || 0, cashback: 0 };
}
if (yr === currentYear) {
// Année en cours : actuel reçu + reste projeté
const bruts_act = remRow?.interets_bruts || 0;
const bruts_proj = projRow?.interets_prevus || 0;
const red = getPfuReduction(yr);
return {
interets_bruts: bruts_act + bruts_proj,
interets_nets: (remRow?.interets_nets || 0) + bruts_proj * (1 - red),
capital: (remRow?.capital || 0) + (projRow?.capital_prevu || 0),
cashback: remRow?.cashback || 0,
};
}
// Année passée : actuel uniquement
return {
interets_bruts: remRow?.interets_bruts || 0,
interets_nets: remRow?.interets_nets || 0,
capital: remRow?.capital || 0,
cashback: remRow?.cashback || 0,
};
};
// ── Données mensuelles (mois en cours + M-1) — uniquement si année courante ──
const { thisMonthRow, prevMonthRow, prevMonthLabel, capitalCurrent, capitalPrev, enDefautCurrent, enDefautPrev } = useMemo(() => {
const MOIS_FR = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.'];
const rows = rawData.rembourses ?? [];
const thisStr = String(currentYear) + '-' + String(currentMonth).padStart(2, '0');
const curr = rows.find(r => r.mois === thisStr) ?? null;
const prevDate = new Date(currentYear, currentMonth - 2, 1);
const prevStr = String(prevDate.getFullYear()) + '-' + String(prevDate.getMonth() + 1).padStart(2, '0');
const prev = rows.find(r => r.mois === prevStr) ?? null;
const label = MOIS_FR[prevDate.getMonth()] + ' ' + prevDate.getFullYear();
// Capital investi et en risque M vs M-1 depuis capitalMensuelData
const capRows = capitalMensuelData ?? [];
const capCurr = capRows.find(c => c.mois === thisStr);
const capPrev = capRows.find(c => c.mois === prevStr);
return {
thisMonthRow: curr, prevMonthRow: prev, prevMonthLabel: label,
capitalCurrent: capCurr?.capital ?? null,
capitalPrev: capPrev?.capital ?? null,
enDefautCurrent: capCurr?.en_defaut ?? null,
enDefautPrev: capPrev?.en_defaut ?? null,
};
}, [rawData, currentYear, currentMonth, capitalMensuelData]);
// ── Données annuelles (année sélectionnée + N-1) ──
const { annualData, prevAnnualData } = useMemo(() => {
if (modeGlobal) {
const total = (rawDataGlobal.rembourses ?? []).reduce((acc, r) => ({
interets_bruts: acc.interets_bruts + (r.interets_bruts || 0),
interets_nets: acc.interets_nets + (r.interets_nets || 0),
capital: acc.capital + (r.capital || 0),
cashback: acc.cashback + (r.cashback || 0),
}), { interets_bruts: 0, interets_nets: 0, capital: 0, cashback: 0 });
return { annualData: total, prevAnnualData: null };
}
return {
annualData: getYearData(Number(annee)),
prevAnnualData: getYearData(Number(annee) - 1),
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rawDataGlobal, annee, modeGlobal, pfuRates]);
// ── Valeur affichée ──
const getValue = (field) => {
if (isCurrentYear) return thisMonthRow?.[field] || 0;
return annualData[field] || 0;
};
// ── Badge et référence ──
const mkBadge = (field) => {
if (modeGlobal) return null;
if (isCurrentYear) {
if (!thisMonthRow || !prevMonthRow) return null;
return <TrendBadge current={thisMonthRow[field] || 0} prev={prevMonthRow[field] || 0} />;
}
if (!prevAnnualData) return null;
return <TrendBadge current={annualData[field] || 0} prev={prevAnnualData[field] || 0} />;
};
const mkRef = (field) => {
if (modeGlobal) return null;
if (isCurrentYear) {
if (!prevMonthRow) return null;
return fmtEUR(prevMonthRow[field] || 0) + ' en ' + prevMonthLabel;
}
if (!prevAnnualData) return null;
const prevYr = Number(annee) - 1;
const suffix = prevYr >= currentYear ? ' (proj.)' : '';
return fmtEUR(prevAnnualData[field] || 0) + ' en ' + prevYr + suffix;
};
const interetsField = netMode ? 'interets_nets' : 'interets_bruts';
// ── Performance annualisée ──────────────────────────────────────
const calcPerfRatio = (interetsVal, capital) =>
capital > 0 && interetsVal != null ? interetsVal / capital : null;
// Mensuelle annualisée (mode année courante)
const capitalDeploye = portfolio.encours + portfolio.en_defaut;
const perfCurrent = isCurrentYear && thisMonthRow && capitalDeploye > 0
? calcPerfRatio(thisMonthRow[interetsField] || 0, capitalDeploye) * 12
: null;
const perfPrev = isCurrentYear && prevMonthRow && capitalDeploye > 0
? calcPerfRatio(prevMonthRow[interetsField] || 0, capitalDeploye) * 12
: null;
// Annuelle (mode année passée/future) — même formule que le tableau :
// (interets + cashback) / capital souscrit cette année-là
const perfInteretsAnnee = (annualData[interetsField] || 0) + (annualData.cashback || 0);
const perfAnnual = !modeGlobal && !isCurrentYear && capitalAnnee > 0
? calcPerfRatio(perfInteretsAnnee, capitalAnnee)
: null;
const prevCapitalAnnee = modeGlobal || isCurrentYear
? capitalDeploye
: (capitalParAnneeMap[Number(annee) - 1] ?? capitalDeploye);
const perfInteretsPrevAnnee = ((prevAnnualData?.[interetsField] || 0) + (prevAnnualData?.cashback || 0));
const perfAnnualPrev = !modeGlobal && !isCurrentYear && prevAnnualData && prevCapitalAnnee > 0
? calcPerfRatio(perfInteretsPrevAnnee, prevCapitalAnnee)
: null;
const perfValue = modeGlobal ? null : (isCurrentYear ? perfCurrent : perfAnnual);
const perfPrevVal = modeGlobal ? null : (isCurrentYear ? perfPrev : perfAnnualPrev);
const perfLabel = netMode ? 'Performance nette annualisée' : 'Performance brute annualisée';
const perfRefLabel = isCurrentYear
? (perfPrevVal != null ? fmtPct(perfPrevVal * 100) + ' en ' + prevMonthLabel : null)
: (perfAnnualPrev != null ? fmtPct(perfAnnualPrev * 100) + ' en ' + (Number(annee) - 1) : null);
return (
<div className="kpi-grid" style={{ flex: 1, marginBottom: 0 }}>
<KpiCard
title="Capital investi"
value={fmtEUR(capitalAnnee)}
badge={isCurrentYear && capitalCurrent != null && capitalPrev != null && capitalPrev > 0
? <TrendBadge current={capitalCurrent} prev={capitalPrev} />
: null}
refValue={isCurrentYear && capitalPrev != null
? fmtEUR(capitalPrev) + ' en ' + prevMonthLabel
: null}
/>
<KpiCard
title="Capital en risque"
value={fmtEUR(portfolio.en_defaut)}
badge={isCurrentYear && enDefautCurrent != null && enDefautPrev != null && enDefautPrev > 0
? <TrendBadge current={enDefautCurrent} prev={enDefautPrev} invert={true} />
: null}
refValue={isCurrentYear && enDefautPrev != null && enDefautPrev > 0
? fmtEUR(enDefautPrev) + ' en ' + prevMonthLabel
: null}
/>
<KpiCard
title={perfLabel + (isFutureYear ? ' (proj.)' : '')}
value={perfValue != null ? fmtPct(perfValue * 100) : '—'}
badge={perfValue != null && perfPrevVal != null
? <TrendBadge current={perfValue} prev={perfPrevVal} />
: null}
refValue={perfRefLabel}
/>
<KpiCard
title={(netMode ? 'Intérêts nets' : 'Intérêts bruts') + (isFutureYear ? ' (proj.)' : '')}
value={fmtEUR(getValue(interetsField))}
badge={mkBadge(interetsField)}
refValue={mkRef(interetsField)}
onClick={() => { setInclureInterets(true); setInclureCapital(false); setInclureCashback(false); }}
/>
<KpiCard
title={'Capital remboursé' + (isFutureYear ? ' (proj.)' : '')}
value={fmtEUR(getValue('capital'))}
badge={mkBadge('capital')}
refValue={mkRef('capital')}
onClick={() => { setInclureInterets(false); setInclureCapital(true); setInclureCashback(false); }}
/>
<KpiCard
title="Cashback reçu"
value={fmtEUR(getValue('cashback'))}
badge={mkBadge('cashback')}
refValue={mkRef('cashback')}
onClick={() => { setInclureInterets(false); setInclureCapital(false); setInclureCashback(true); }}
/>
</div>
);
}
export default function Dashboard() {
const { activeId, activeView, activeViewMember, investisseurs } = useInvestisseur();
const { displayMode } = useUi();
const netMode = displayMode === 'net';
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [data, setData] = useState(null);
const [pfuRates, setPfuRates] = useState([]);
const [loading, setLoading] = useState(false);
const [capitalMensuelData, setCapitalMensuelData] = useState([]);
const [plateformes, setPlateformes] = useState([]);
/* ── drillCell : cellule sélectionnée dans le TIP — par défaut mois courant toutes plateformes ── */
const _now = new Date();
const _moisLabels = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
/* Restaure le drillCell depuis les params URL (retour depuis Remboursements) */
const _initDrillCell = () => {
const ba = searchParams.get('drill-annee');
const bm = searchParams.get('drill-mois');
const bp = searchParams.get('drill-plat');
if (ba && bm) {
const annee = Number(ba), mois = Number(bm);
return {
platId: bp ? Number(bp) : null,
platNom: null,
annee,
mois,
moisLabel: _moisLabels[mois - 1],
};
}
return {
platId: null,
platNom: null,
annee: _now.getFullYear(),
mois: _now.getMonth() + 1,
moisLabel: _moisLabels[_now.getMonth()],
};
};
const [drillCell, setDrillCell] = useState(_initDrillCell);
/* Nettoie les params URL de retour dès le premier rendu */
useEffect(() => {
if (searchParams.get('drill-annee')) {
setSearchParams({}, { replace: true });
}
}, []); /* eslint-disable-next-line */
useEffect(() => {
api.get('/pfu').then(setPfuRates).catch(() => {});
api.get('/plateformes').then(setPlateformes).catch(() => {});
}, []);
useEffect(() => {
if (!activeId && activeView !== 'all') return;
setData(null);
setLoading(true);
const params = activeView === 'all' ? { scope: 'all' } : undefined;
api.get('/dashboard', params).then(setData).finally(() => setLoading(false));
}, [activeView, activeId]);
const ready = activeView === 'all' ? investisseurs.length > 0 : !!activeId;
if (!ready) return <div className="card text-muted">Sélectionnez un compte investisseur.</div>;
if (loading || !data) return <div className="card text-muted">Chargement</div>;
const { cash, cashByPlatform, portfolio, interets, interetsParAnnee } = data;
const viewTitle = activeView === 'all'
? 'Famille et entreprises'
: memberLabel(activeViewMember);
return (
<>
<div className="topbar"><h2><PageIcon name="dashboard" />Tableau de bord {viewTitle}</h2></div>
<InteretsChartProvider netMode={netMode} pfuRates={pfuRates} activeView={activeView} activeId={activeId}>
{/* ── KPI + sélecteur année ── */}
<div style={{ display: 'flex', gap: 12, alignItems: 'stretch', marginBottom: 16 }}>
<DashboardKpis portfolio={portfolio} netMode={netMode} pfuRates={pfuRates} capitalMensuelData={capitalMensuelData} />
<YearSelectorKpi />
</div>
{/* ── Graphiques ── */}
<div style={{ display: 'flex', gap: 16, alignItems: 'stretch', marginTop: 8, marginBottom: 24 }}>
<div style={{ flex: 2, minWidth: 0 }}>
<InteretsMensuelsChart />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<InteretsDonutChart />
</div>
</div>
{/* ── Tableau intérêts par plateforme ── */}
<div style={{ marginBottom: 0 }}>
<TableauInteretsPlateforme
activeView={activeView}
activeId={activeId}
pfuRates={pfuRates}
onCapitalMensuel={setCapitalMensuelData}
onCellClick={({ platId, platNom, annee, mois, moisLabel }) =>
setDrillCell({ platId, platNom, annee, mois, moisLabel })
}
activeCell={drillCell}
/>
</div>
{/* ── Panneau détail mois / plateforme (remplace les Échéances prévues) ── */}
<DrillCellPanel
cell={drillCell}
alwaysOpen={true}
pfuRates={pfuRates}
activeView={activeView}
activeId={activeId}
plateformes={plateformes}
investissements={[]}
onBulkDone={() => {}}
onEditRecu={(r) => {
const q = new URLSearchParams({
'edit-remb': r.id,
from: 'dashboard',
'drill-annee': drillCell.annee,
'drill-mois': drillCell.mois,
...(drillCell.platId ? { 'drill-plat': drillCell.platId } : {}),
});
navigate(`/remboursements?${q}`);
}}
onEditProjet={(p) => {
const q = new URLSearchParams({
'open-simul': p.investissement_id,
'simul-date': p.date_prevue,
'simul-capital': p.capital_prevu ?? 0,
'simul-interets': p.interets_prevus ?? 0,
from: 'dashboard',
'drill-annee': drillCell.annee,
'drill-mois': drillCell.mois,
...(drillCell.platId ? { 'drill-plat': drillCell.platId } : {}),
});
navigate(`/remboursements?${q}`);
}}
/>
</InteretsChartProvider>
</>
);
}
File diff suppressed because it is too large Load Diff
+330
View File
@@ -0,0 +1,330 @@
import { useEffect, useRef, useState } from 'react';
import { api } from '../api.js';
import { memberInitials, memberDisplayName } from '../utils/format.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import Modal from '../components/Modal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx';
/* ── Avatar ─────────────────────────────────────────────────── */
function MemberAvatar({ membre, size = 40 }) {
const initials = memberInitials(membre);
const bg = membre.type === 'entreprise'
? 'linear-gradient(135deg, #4f46e5 0%, #3730a3 100%)'
: 'linear-gradient(135deg, #1e40af 0%, #1e3a8a 100%)';
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: bg,
color: 'white',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 700, fontSize: Math.round(size * 0.35),
flexShrink: 0, letterSpacing: '.03em', userSelect: 'none',
}}>
{initials}
</div>
);
}
/* ── Menu "···" par membre ──────────────────────────────────── */
function MemberMenu({ onEdit, onDelete, isPrincipal, isOnly }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const h = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
return (
<div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
<button className="member-dots-btn" onClick={() => setOpen(o => !o)}
aria-label="Actions" aria-haspopup="menu">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/>
</svg>
</button>
{open && (
<div className="member-dots-menu" role="menu">
<button className="member-dots-menu-item" role="menuitem" onClick={() => { setOpen(false); onEdit(); }}>
Modifier
</button>
{!isPrincipal && (
<button className="member-dots-menu-item danger-item" role="menuitem"
disabled={isOnly}
title={isOnly ? 'Impossible de supprimer le dernier profil' : ''}
onClick={() => { setOpen(false); onDelete(); }}>
Supprimer
</button>
)}
</div>
)}
</div>
);
}
/* ── Ligne "Ajouter" ─────────────────────────────────────────── */
function AddRow({ label, onClick }) {
return (
<div className="member-add-row" onClick={onClick} role="button" tabIndex={0}
onKeyDown={e => e.key === 'Enter' && onClick()}>
<div className="member-add-circle">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</div>
<span>{label}</span>
</div>
);
}
/* ── Composant principal ─────────────────────────────────────── */
export default function FamilleEntreprises() {
const { reload: reloadCtx } = useInvestisseur();
const [membres, setMembres] = useState([]);
const [tab, setTab] = useState('famille');
const [err, setErr] = useState(null);
/* Modals */
const [modalFamille, setModalFamille] = useState(false);
const [modalEntreprise, setModalEntreprise] = useState(false);
const [editTarget, setEditTarget] = useState(null); // membre à éditer
/* Formulaires */
const emptyFam = { prenom: '', nom_famille: '' };
const emptyEnt = { nom: '', type_fiscal: 'PM' };
const [famForm, setFamForm] = useState(emptyFam);
const [entForm, setEntForm] = useState(emptyEnt);
const [saving, setSaving] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(null);
const load = async () => {
const list = await api.get('/investisseurs');
setMembres(list);
};
useEffect(() => { load(); }, []);
const famille = membres.filter(m => m.type === 'famille');
const entreprises = membres.filter(m => m.type === 'entreprise');
const totalCount = membres.length;
/* ── Ouvrir modal édition ─────────────────────────────────── */
const openEdit = (m) => {
setEditTarget(m);
if (m.type === 'famille') {
const restNom = m.prenom ? m.nom.replace(m.prenom, '').trim() : m.nom;
setFamForm({ prenom: m.prenom || '', nom_famille: restNom });
setModalFamille(true);
} else {
setEntForm({ nom: m.nom, type_fiscal: m.type_fiscal || 'PM' });
setModalEntreprise(true);
}
};
const closeModals = () => {
setModalFamille(false); setModalEntreprise(false);
setEditTarget(null);
setFamForm(emptyFam); setEntForm(emptyEnt);
setErr(null);
};
/* ── Sauvegarde famille ────────────────────────────────────── */
const saveFamille = async (e) => {
e.preventDefault(); setErr(null); setSaving(true);
try {
const fullName = [famForm.prenom.trim(), famForm.nom_famille.trim()].filter(Boolean).join(' ');
if (!fullName) throw new Error('Veuillez renseigner au moins un prénom ou un nom.');
const payload = {
nom: fullName,
prenom: famForm.prenom.trim() || null,
type: 'famille',
type_fiscal: 'PP',
};
if (editTarget) {
await api.put(`/investisseurs/${editTarget.id}`, payload);
} else {
await api.post('/investisseurs', payload);
}
await load(); await reloadCtx();
closeModals();
} catch (e) { setErr(e.message); }
finally { setSaving(false); }
};
/* ── Sauvegarde entreprise ─────────────────────────────────── */
const saveEntreprise = async (e) => {
e.preventDefault(); setErr(null); setSaving(true);
try {
const payload = {
nom: entForm.nom.trim(),
prenom: null,
type: 'entreprise',
type_fiscal: entForm.type_fiscal,
};
if (editTarget) {
await api.put(`/investisseurs/${editTarget.id}`, payload);
} else {
await api.post('/investisseurs', payload);
}
await load(); await reloadCtx();
closeModals();
} catch (e) { setErr(e.message); }
finally { setSaving(false); }
};
/* ── Suppression ──────────────────────────────────────────── */
const deleteMembre = (m) => {
setDeleteConfirm({
message: `Supprimer "${memberDisplayName(m)}" ? Tous les investissements associés seront effacés.`,
onConfirm: async () => {
try {
await api.del(`/investisseurs/${m.id}`);
await load(); await reloadCtx();
} catch (e) { setErr(e.message); }
finally { setDeleteConfirm(null); }
},
});
};
/* ── Render ───────────────────────────────────────────────── */
const currentList = tab === 'famille' ? famille : entreprises;
return (
<div className="famille-wrap">
{/* Tabs */}
<div className="famille-tabs">
<button
className={`famille-tab${tab === 'famille' ? ' active' : ''}`}
onClick={() => setTab('famille')}
>
Famille
<span className="famille-tab-count">{famille.length}</span>
</button>
<button
className={`famille-tab${tab === 'entreprise' ? ' active' : ''}`}
onClick={() => setTab('entreprise')}
>
Entreprises
<span className="famille-tab-count">{entreprises.length}</span>
</button>
</div>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
{/* Liste */}
<div className="membre-list">
{currentList.map(m => (
<div key={m.id} className="membre-row">
<MemberAvatar membre={m} size={42} />
<div className="membre-info">
<span className="membre-name">{memberDisplayName(m)}</span>
{m.type === 'famille' && (
<span className="membre-role">
{m.is_principal ? '(compte principal)' : '(Membre de la famille)'}
</span>
)}
</div>
{m.type_fiscal && m.type === 'entreprise' && (
<span className="membre-badge">{m.type_fiscal}</span>
)}
<MemberMenu
onEdit={() => openEdit(m)}
onDelete={() => deleteMembre(m)}
isPrincipal={!!m.is_principal}
isOnly={totalCount <= 1}
/>
</div>
))}
{/* Ligne d'ajout */}
{tab === 'famille' && (
<AddRow label="Ajouter une personne"
onClick={() => { setEditTarget(null); setFamForm(emptyFam); setModalFamille(true); }} />
)}
{tab === 'entreprise' && (
<AddRow label="Ajouter une entreprise"
onClick={() => { setEditTarget(null); setEntForm(emptyEnt); setModalEntreprise(true); }} />
)}
</div>
{/* ── Modal famille ──────────────────────────────────────── */}
<Modal
open={modalFamille}
title={editTarget ? 'Modifier le membre' : 'Ajouter une personne'}
onClose={closeModals}
footer={
<>
<button className="ghost" type="button" onClick={closeModals}>Annuler</button>
<button className="primary" form="form-famille" type="submit" disabled={saving}>
{saving ? '…' : 'Enregistrer'}
</button>
</>
}
>
<form id="form-famille" onSubmit={saveFamille}
style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{err && <div className="error">{err}</div>}
<div className="modal-field">
<label>Prénom</label>
<input autoFocus value={famForm.prenom}
onChange={e => setFamForm({ ...famForm, prenom: e.target.value })}
placeholder="Olivier" />
</div>
<div className="modal-field">
<label>Nom de famille <span className="text-muted" style={{ fontWeight: 400 }}>(optionnel)</span></label>
<input value={famForm.nom_famille}
onChange={e => setFamForm({ ...famForm, nom_famille: e.target.value })}
placeholder="CROGUENNEC" />
</div>
</form>
</Modal>
{/* ── Modal entreprise ───────────────────────────────────── */}
<Modal
open={modalEntreprise}
title={editTarget ? "Modifier l'entreprise" : 'Ajouter une entreprise'}
onClose={closeModals}
footer={
<>
<button className="ghost" type="button" onClick={closeModals}>Annuler</button>
<button className="primary" form="form-entreprise" type="submit" disabled={saving}>
{saving ? '…' : 'Enregistrer'}
</button>
</>
}
>
<form id="form-entreprise" onSubmit={saveEntreprise}
style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{err && <div className="error">{err}</div>}
<div className="modal-field">
<label>Nom de l'entreprise *</label>
<input autoFocus required value={entForm.nom}
onChange={e => setEntForm({ ...entForm, nom: e.target.value })}
placeholder="SCI Famille Croguennec" />
</div>
<div className="modal-field">
<label>Forme juridique</label>
<select value={entForm.type_fiscal}
onChange={e => setEntForm({ ...entForm, type_fiscal: e.target.value })}>
<option value="PM">Personne morale</option>
<option value="SCI">SCI</option>
<option value="SCPI">SCPI</option>
<option value="SARL">SARL</option>
<option value="SAS">SAS</option>
<option value="SA">SA</option>
</select>
</div>
</form>
</Modal>
<ConfirmModal
open={!!deleteConfirm}
message={deleteConfirm?.message}
onConfirm={deleteConfirm?.onConfirm}
onCancel={() => setDeleteConfirm(null)}
/>
</div>
);
}
+455
View File
@@ -0,0 +1,455 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { fmtDate } from '../utils/format.js';
import ResultBanner from '../components/ResultBanner.jsx';
const MODULES = {
depots_retraits: {
label: 'Dépôts / Retraits',
required: ['date_operation', 'type', 'montant'],
optional: ['plateforme_id', 'libelle', 'reference'],
needsInvestisseur: true,
},
investissements: {
label: 'Investissements',
required: ['nom_projet', 'date_souscription', 'montant_investi'],
optional: ['plateforme_id', 'emetteur', 'date_premiere_echeance', 'date_cible', 'taux_interet', 'duree_mois', 'type_remb', 'freq_interets', 'statut', 'reference'],
needsInvestisseur: true,
},
remboursements: {
label: 'Remboursements',
required: ['investissement_id', 'date_remb'],
optional: ['capital', 'interets_bruts', 'prelev_sociaux', 'prelev_forfaitaire', 'net_recu', 'statut'],
needsInvestisseur: true,
},
plateformes: {
label: 'Plateformes',
required: ['nom'],
optional: ['url', 'notes'],
needsInvestisseur: false,
note: 'Les plateformes dont le nom existe déjà seront ignorées (pas d\'écrasement).',
},
taux_pfu: {
label: 'Flat Tax — Taux PFU',
required: ['annee', 'pfu_total', 'impot_revenu', 'prelev_sociaux'],
optional: [],
needsInvestisseur: false,
global: true,
note: 'Table de référence globale. Si une année existe déjà, ses taux seront mis à jour (upsert).',
},
};
const MODULE_LABEL = {
depots_retraits: 'Dépôts / Retraits',
investissements: 'Investissements',
remboursements: 'Remboursements',
plateformes: 'Plateformes',
taux_pfu: 'Flat Tax — Taux PFU',
};
export default function Imports() {
const { activeId } = useInvestisseur();
const navigate = useNavigate();
// ── Import classique (xlsx/csv/json) ──────────────────────────
const [module, setModule] = useState('depots_retraits');
const [file, setFile] = useState(null);
const [preview, setPreview] = useState(null);
const [mapping, setMapping] = useState({});
const [defaults, setDefaults] = useState({});
const [plats, setPlats] = useState([]);
const [investissements,setInvestissements] = useState([]);
const [history, setHistory] = useState([]);
const [result, setResult] = useState(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState(null);
// ── Import dossier investissement ─────────────────────────────
const [dossierFile, setDossierFile] = useState(null);
const [dossierPreview, setDossierPreview] = useState(null); // parsed JSON for review
const [dossierResult, setDossierResult] = useState(null);
const [dossierBusy, setDossierBusy] = useState(false);
const [dossierErr, setDossierErr] = useState(null);
const dossierInputRef = useRef(null);
// History + plateformes sont user-scoped → chargement sans activeId
useEffect(() => {
api.get('/imports/history').then(setHistory).catch(() => {});
api.get('/plateformes').then(setPlats).catch(() => {});
}, []);
// Investissements sont investisseur-scoped → besoin de activeId
useEffect(() => {
if (!activeId) return;
api.get('/investissements').then(setInvestissements).catch(() => {});
}, [activeId]);
const def = MODULES[module];
const allTargets = def ? [...def.required, ...def.optional] : [];
const missingInv = def?.needsInvestisseur && !activeId;
const onPreview = async () => {
if (!file) return;
setBusy(true); setErr(null); setResult(null);
try {
const fd = new FormData();
fd.append('file', file);
const r = await api.upload('/imports/preview', fd);
setPreview(r);
// Auto-map colonnes dont le nom correspond à une cible
const auto = {};
for (const t of allTargets) {
const col = r.headers.find(h => h.toLowerCase().replace(/\W/g, '_') === t);
if (col) auto[t] = col;
}
setMapping(auto);
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
};
const apply = async () => {
setBusy(true); setErr(null);
try {
const r = await api.post('/imports/apply', {
tempId: preview.tempId, module, mapping, defaults,
originalFilename: file?.name ?? preview.filename,
});
setResult({
ok: true,
msg: `✔ Import terminé : ${r.inserted} / ${r.total} lignes insérées${r.skipped > 0 ? `, ${r.skipped} ignorées` : ''}.${r.errors?.length > 0 ? ` (${r.errors.length} avertissement(s))` : ''}`,
});
setPreview(null); setFile(null); setMapping({}); setDefaults({});
api.get('/imports/history').then(setHistory).catch(() => {});
// Recharger les plateformes si c'est ce qui vient d'être importé
if (module === 'plateformes') {
api.get('/plateformes').then(setPlats).catch(() => {});
}
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
};
return (
<>
<div className="topbar"><h2>Import Données</h2></div>
<div className="card">
<h3 style={{ marginTop: 0 }}>1. Fichier source</h3>
<div className="row">
<div>
<label>Module cible</label>
<select value={module} onChange={e => {
setModule(e.target.value);
setPreview(null); setMapping({}); setResult(null); setErr(null);
}}>
{Object.entries(MODULES).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
</div>
<div style={{ flex: 2 }}>
<label>Fichier .xlsx, .csv ou .json</label>
<input type="file" accept=".xlsx,.xls,.csv,.json" onChange={e => {
setFile(e.target.files[0]);
setPreview(null); setResult(null); setErr(null);
}} />
</div>
<div>
<button className="primary" onClick={onPreview}
disabled={!file || busy || missingInv}>
{busy ? '…' : 'Analyser'}
</button>
</div>
</div>
{/* Note contextuelle du module sélectionné */}
{def?.note && (
<div className="import-module-note">
{def.global && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ flexShrink: 0, marginTop: 1, color: 'var(--warning)' }}>
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
)}
{def.note}
</div>
)}
{/* Avertissement si module investisseur-scoped mais aucun investisseur actif */}
{missingInv && (
<div className="error" style={{ marginTop: 10 }}>
Sélectionnez un investisseur actif avant d'importer ce module.
</div>
)}
{err && <div className="error" style={{ marginTop: 12 }}>{err}</div>}
<ResultBanner result={result} onDismiss={() => setResult(null)} style={{ marginTop: 12 }} />
</div>
{preview && (
<>
<div className="card">
<h3 style={{ marginTop: 0 }}>2. Mappage des colonnes</h3>
<p className="text-muted" style={{ fontSize: 12 }}>
Fichier : <strong>{preview.filename}</strong> — feuille <em>{preview.sheetName}</em> — {preview.allRowCount} lignes.
{' '}Champs marqués <span style={{ color: 'var(--danger)' }}>*</span> obligatoires.
{' '}Si la colonne n'existe pas, fournissez une valeur par défaut.
</p>
<table>
<thead>
<tr>
<th>Champ cible</th>
<th>Colonne Excel</th>
<th>Valeur par défaut</th>
</tr>
</thead>
<tbody>
{allTargets.map(t => {
const isReq = def.required.includes(t);
return (
<tr key={t}>
<td>
<code style={{ fontSize: 11 }}>{t}</code>
{isReq && <span style={{ color: 'var(--danger)' }}> *</span>}
</td>
<td>
<select value={mapping[t] || ''}
onChange={e => setMapping({ ...mapping, [t]: e.target.value })}>
<option value=""> ignorer </option>
{preview.headers.map(h => <option key={h} value={h}>{h}</option>)}
</select>
</td>
<td>
{t === 'plateforme_id' ? (
<select value={defaults[t] || ''}
onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}>
<option value=""></option>
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? `${p.investisseur_nom}` : ''}</option>)}
</select>
) : t === 'investissement_id' ? (
<select value={defaults[t] || ''}
onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}>
<option value=""></option>
{investissements.map(i => <option key={i.id} value={i.id}>{i.nom_projet}</option>)}
</select>
) : (
<input value={defaults[t] || ''}
onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}
placeholder={t === 'statut' ? 'ex. en_cours' : t === 'type' ? 'ex. depot' : ''} />
)}
</td>
</tr>
);
})}
</tbody>
</table>
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => { setPreview(null); setMapping({}); }}>Annuler</button>
<button className="primary" onClick={apply} disabled={busy || missingInv}>
{busy ? '…' : `Importer ${preview.allRowCount} lignes`}
</button>
</div>
</div>
<div className="card">
<h3 style={{ marginTop: 0 }}>Aperçu (10 premières lignes)</h3>
<div style={{ overflowX: 'auto' }}>
<table>
<thead>
<tr>{preview.headers.map(h => <th key={h}>{h}</th>)}</tr>
</thead>
<tbody>
{preview.sampleRows.map((r, i) => (
<tr key={i}>{preview.headers.map(h => <td key={h}>{String(r[h] ?? '')}</td>)}</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
{/* ── Import Dossier Investissement ──────────────────────── */}
<DossierImport
activeId={activeId}
navigate={navigate}
dossierFile={dossierFile}
setDossierFile={setDossierFile}
dossierPreview={dossierPreview}
setDossierPreview={setDossierPreview}
dossierResult={dossierResult}
setDossierResult={setDossierResult}
dossierBusy={dossierBusy}
setDossierBusy={setDossierBusy}
dossierErr={dossierErr}
setDossierErr={setDossierErr}
dossierInputRef={dossierInputRef}
reloadHistory={() => api.get('/imports/history').then(setHistory).catch(() => {})}
/>
<div className="card">
<h3 style={{ marginTop: 0 }}>Historique des imports</h3>
<table>
<thead>
<tr>
<th>Date</th>
<th>Module</th>
<th>Fichier</th>
<th className="num">Total</th>
<th className="num">OK</th>
<th className="num">KO</th>
</tr>
</thead>
<tbody>
{history.length === 0 && (
<tr><td colSpan={6} className="text-muted" style={{ textAlign: 'center' }}>Aucun import</td></tr>
)}
{history.map(h => (
<tr key={h.id}>
<td>{fmtDate(h.created_at)}</td>
<td>{MODULE_LABEL[h.module] ?? h.module}</td>
<td className="text-muted" style={{ fontSize: 11 }}>{h.filename}</td>
<td className="num">{h.rows_total}</td>
<td className="num" style={{ color: 'var(--success)' }}>{h.rows_inserted}</td>
<td className="num" style={{ color: h.rows_skipped > 0 ? 'var(--warning)' : undefined }}>
{h.rows_skipped}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}
/* ── Composant import dossier ──────────────────────────────────── */
function DossierImport({
activeId, navigate,
dossierFile, setDossierFile,
dossierPreview, setDossierPreview,
dossierResult, setDossierResult,
dossierBusy, setDossierBusy,
dossierErr, setDossierErr,
dossierInputRef, reloadHistory,
}) {
const missingInv = !activeId;
const onFileChange = (e) => {
const f = e.target.files[0];
setDossierFile(f || null);
setDossierPreview(null);
setDossierResult(null);
setDossierErr(null);
if (!f) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const parsed = JSON.parse(ev.target.result);
if (parsed.type !== 'dossier_investissement') {
setDossierErr('Ce fichier n\'est pas un dossier investissement valide (type incorrect).');
return;
}
setDossierPreview(parsed);
} catch {
setDossierErr('Fichier JSON invalide — vérifiez la syntaxe.');
}
};
reader.readAsText(f);
};
const onImport = async () => {
if (!dossierPreview) return;
setDossierBusy(true); setDossierErr(null); setDossierResult(null);
try {
const r = await api.post('/imports/dossier', { dossier: dossierPreview });
setDossierResult(r);
setDossierFile(null); setDossierPreview(null);
if (dossierInputRef.current) dossierInputRef.current.value = '';
reloadHistory();
} catch (e) { setDossierErr(e.message); }
finally { setDossierBusy(false); }
};
const dp = dossierPreview;
const inv = dp?.investissement;
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
return (
<div className="card">
<h3 style={{ marginTop: 0 }}>Import Dossier investissement</h3>
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', marginBottom: 12 }}>
Restaure ou migre un dossier complet (investissement + remboursements + historique) depuis un fichier
<code style={{ margin: '0 4px' }}>.json</code> exporté par cette application.
Si le dossier existe déjà, il sera mis à jour ; sinon il sera créé.
</p>
{missingInv && (
<div className="error" style={{ marginBottom: 10 }}>
Sélectionnez un investisseur actif avant d'importer un dossier.
</div>
)}
<div className="row" style={{ gap: 10, alignItems: 'flex-end' }}>
<div style={{ flex: 1 }}>
<label>Fichier dossier <code>.json</code></label>
<input
ref={dossierInputRef}
type="file" accept=".json"
disabled={missingInv}
onChange={onFileChange}
/>
</div>
</div>
{dossierErr && <div className="error" style={{ marginTop: 10 }}>{dossierErr}</div>}
{/* Aperçu du dossier avant import */}
{dp && inv && (
<div style={{ marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 14 }}>
<h4 style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)' }}>Aperçu du dossier</h4>
<table style={{ marginBottom: 0 }}>
<tbody>
<tr><td style={{ width: 200 }}>Projet</td><td><strong>{inv.nom_projet}</strong></td></tr>
<tr><td>Plateforme</td><td>{dp.plateforme?.nom}</td></tr>
<tr><td>Date souscription</td><td>{fmtDate(inv.date_souscription)}</td></tr>
<tr><td>Montant investi</td><td>{inv.montant_investi} €</td></tr>
<tr><td>Statut</td><td>{inv.statut}</td></tr>
<tr><td>Remboursements</td><td>{dp.remboursements?.length ?? 0} enregistrement(s)</td></tr>
<tr><td>Projections</td><td>{dp.projections?.length ?? 0} échéance(s)</td></tr>
<tr><td>Historique</td><td>{dp.historique?.length ?? 0} entrée(s)</td></tr>
<tr><td>Exporté le</td><td className="text-muted" style={{ fontSize: 11 }}>{dp.exported_at}</td></tr>
</tbody>
</table>
<div style={{ marginTop: 12, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button onClick={() => { setDossierFile(null); setDossierPreview(null); if (dossierInputRef.current) dossierInputRef.current.value = ''; }}>
Annuler
</button>
<button className="primary" onClick={onImport} disabled={dossierBusy || missingInv}>
{dossierBusy ? '' : 'Importer ce dossier'}
</button>
</div>
</div>
)}
{dossierResult && (
<div className="success-msg" style={{ marginTop: 12 }}>
{dossierResult.action === 'created'
? ' Dossier créé avec succès.'
: ' Dossier mis à jour avec succès.'
}
{' '}
<button
style={{ marginLeft: 8, fontSize: 'var(--fs-xs)', padding: '2px 8px' }}
onClick={() => navigate(`/investissements/${dossierResult.investissementId}`)}
>
Ouvrir le dossier
</button>
</div>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';
export default function Login() {
const { login } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [err, setErr] = useState(null);
const [busy, setBusy] = useState(false);
const submit = async (e) => {
e.preventDefault();
setErr(null); setBusy(true);
try {
await login(email, password);
navigate('/');
} catch (e) {
setErr(e.message);
} finally { setBusy(false); }
};
return (
<div className="login-shell">
<form className="card login-card" onSubmit={submit}>
<h2 style={{ marginTop: 0 }}>Connexion</h2>
{err && <div className="error">{err}</div>}
<label>Email</label>
<input type="email" required value={email} onChange={e => setEmail(e.target.value)} />
<div style={{ height: 10 }} />
<label>Mot de passe</label>
<input type="password" required value={password} onChange={e => setPassword(e.target.value)} />
<div style={{ height: 16 }} />
<button className="primary" type="submit" disabled={busy} style={{ width: '100%' }}>
{busy ? '…' : 'Se connecter'}
</button>
<p className="text-muted" style={{ marginTop: 16, textAlign: 'center' }}>
Pas encore de compte ? <Link to="/register">Créer un compte</Link>
</p>
</form>
</div>
);
}
+286
View File
@@ -0,0 +1,286 @@
import { useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';
import { useUi } from '../context/UiContext.jsx';
import { api } from '../api.js';
/* ── Icônes nav ─────────────────────────────────────────────── */
function IconUser() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>;
}
function IconLock() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>;
}
/* ── Dropdown custom style Finary ────────────────────────────── */
const LANGUES = [
{ value: 'fr', label: 'Français' },
{ value: 'en', label: 'English' },
];
const DEVISES = [
{ value: 'EUR', label: '€ - EUR' },
{ value: 'USD', label: '$ - USD' },
{ value: 'GBP', label: '£ - GBP' },
{ value: 'CHF', label: 'CHF' },
{ value: 'CAD', label: 'CA$ - CAD' },
{ value: 'SGD', label: 'SGD' },
];
function ProfileSelect({ label, options, value, onChange }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const h = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
const selected = options.find(o => o.value === value);
return (
<div ref={ref} className="profile-field">
<span className="profile-label">{label}</span>
<div className={`profile-select-trigger${open ? ' open' : ''}`}
onClick={() => setOpen(o => !o)} role="button" tabIndex={0}
onKeyDown={e => e.key === 'Enter' && setOpen(o => !o)}>
<span>{selected?.label}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ transition: 'transform .15s', transform: open ? 'rotate(180deg)' : 'rotate(0deg)' }}
aria-hidden="true">
<path d="M6 9l6 6 6-6"/>
</svg>
</div>
{open && (
<div className="profile-select-dropdown" role="listbox">
{options.map(o => (
<div key={o.value}
className={`profile-select-option${o.value === value ? ' selected' : ''}`}
role="option" aria-selected={o.value === value}
onClick={() => { onChange(o.value); setOpen(false); }}>
{o.label}
{o.value === value && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
aria-hidden="true">
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
</div>
))}
</div>
)}
</div>
);
}
/* ── Mon profil + Préférences ────────────────────────────────── */
function AccountForm() {
const { user, updateUser } = useAuth();
const { langue, setLangue, devise, setDevise } = useUi();
/* Découpe display_name en prénom / nom */
const parseName = (dn = '') => {
const parts = dn.trim().split(' ');
return parts.length >= 2
? { prenom: parts[0], nom: parts.slice(1).join(' ') }
: { prenom: dn.trim(), nom: '' };
};
const initial = parseName(user?.display_name);
const [prenom, setPrenom] = useState(initial.prenom);
const [nom, setNom] = useState(initial.nom);
const [infoMsg, setInfoMsg] = useState(null);
const [infoErr, setInfoErr] = useState(null);
const [loading, setLoading] = useState(false);
const save = async () => {
setInfoErr(null); setInfoMsg(null); setLoading(true);
try {
const displayName = [prenom.trim(), nom.trim()].filter(Boolean).join(' ');
await updateUser({ displayName });
setInfoMsg('Profil mis à jour.');
} catch (err) { setInfoErr(err.message); }
finally { setLoading(false); }
};
/* Sauvegarde auto à la perte du focus */
const handleBlur = () => save();
return (
<div className="profile-page">
{/* ── Mon profil ──────────────────────────────────────── */}
<section className="profile-section">
<h2 className="profile-section-title">Mon profil</h2>
{infoErr && <div className="error" style={{ marginBottom: 12 }}>{infoErr}</div>}
{infoMsg && <div className="success-msg" style={{ marginBottom: 12 }}>{infoMsg}</div>}
<div className="profile-grid-2">
<div className="profile-field">
<span className="profile-label">Prénom</span>
<input className="profile-input" value={prenom}
onChange={e => setPrenom(e.target.value)}
onBlur={handleBlur}
placeholder="Prénom" />
</div>
<div className="profile-field">
<span className="profile-label">Nom</span>
<input className="profile-input" value={nom}
onChange={e => setNom(e.target.value)}
onBlur={handleBlur}
placeholder="NOM" />
</div>
</div>
<div className="profile-field profile-field-full">
<span className="profile-label">Mon email</span>
<div className="profile-email-row">
<span className="profile-email-value">{user?.email}</span>
</div>
</div>
<div style={{ marginTop: 20 }}>
<button className="profile-manage-btn" type="button" disabled>
Gérer mon email
</button>
</div>
</section>
{/* ── Préférences ─────────────────────────────────────── */}
<section className="profile-section">
<h2 className="profile-section-title">Préférences</h2>
<div className="profile-grid-2">
<ProfileSelect label="Langue" options={LANGUES} value={langue} onChange={setLangue} />
<ProfileSelect label="Devise" options={DEVISES} value={devise} onChange={setDevise} />
</div>
</section>
{loading && <p className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>Enregistrement</p>}
</div>
);
}
/* ── Sécurité — Mot de passe ─────────────────────────────────── */
function SecurityForm() {
const { updateUser } = useAuth();
const [pwdForm, setPwdForm] = useState({ currentPassword: '', newPassword: '', confirm: '' });
const [pwdMsg, setPwdMsg] = useState(null);
const [pwdErr, setPwdErr] = useState(null);
const [pwdLoading, setPwdLoading] = useState(false);
const savePwd = async (e) => {
e.preventDefault(); setPwdErr(null); setPwdMsg(null);
if (pwdForm.newPassword !== pwdForm.confirm) { setPwdErr('Les mots de passe ne correspondent pas.'); return; }
if (pwdForm.newPassword.length < 8) { setPwdErr('8 caractères minimum.'); return; }
setPwdLoading(true);
try {
await updateUser({ currentPassword: pwdForm.currentPassword, newPassword: pwdForm.newPassword });
setPwdMsg('Mot de passe modifié avec succès.');
setPwdForm({ currentPassword: '', newPassword: '', confirm: '' });
} catch (err) { setPwdErr(err.message); }
finally { setPwdLoading(false); }
};
const handleBackfillComptes = async () => {
setLoadingBackfill(true);
setErrorMsg(null);
setSuccessMsg(null);
try {
const { updated, total } = await api.post('/remboursements/backfill-comptes', {});
if (updated === 0) {
setSuccessMsg(`Aucun remboursement à corriger (${total} vérifié${total > 1 ? 's' : ''}).`);
} else {
setSuccessMsg(`${updated} remboursement${updated > 1 ? 's' : ''} mis à jour sur ${total} vérifié${total > 1 ? 's' : ''}.`);
}
setShowBackfillModal(false);
} catch (err) {
setErrorMsg(err.message || 'Une erreur est survenue.');
setShowBackfillModal(false);
} finally {
setLoadingBackfill(false);
}
};
return (
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Mot de passe</h3>
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
Saisissez votre mot de passe actuel puis choisissez-en un nouveau (8 caractères minimum).
</p>
{pwdErr && <div className="error">{pwdErr}</div>}
{pwdMsg && <div className="success-msg">{pwdMsg}</div>}
<form onSubmit={savePwd}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 400 }}>
<div>
<label>Mot de passe actuel</label>
<input type="password" required autoComplete="current-password"
value={pwdForm.currentPassword}
onChange={e => setPwdForm({ ...pwdForm, currentPassword: e.target.value })} />
</div>
<div>
<label>Nouveau mot de passe</label>
<input type="password" required autoComplete="new-password"
value={pwdForm.newPassword}
onChange={e => setPwdForm({ ...pwdForm, newPassword: e.target.value })} />
</div>
<div>
<label>Confirmer le nouveau mot de passe</label>
<input type="password" required autoComplete="new-password"
value={pwdForm.confirm}
onChange={e => setPwdForm({ ...pwdForm, confirm: e.target.value })} />
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
<button className="primary" type="submit" disabled={pwdLoading}>
{pwdLoading ? 'Modification…' : 'Modifier le mot de passe'}
</button>
</div>
</form>
</div>
);
}
/* ── Page principale ─────────────────────────────────────────── */
export default function MonCompte() {
const { search } = useLocation();
const navigate = useNavigate();
const section = new URLSearchParams(search).get('section') || 'profil';
const setSection = (s) => navigate(`/compte?section=${s}`, { replace: true });
const SECTIONS = [
{ id: 'profil', label: 'Mon compte', icon: <IconUser /> },
{ id: 'securite', label: 'Sécurité', icon: <IconLock /> },
];
return (
<div className="account-layout">
{/* ── Nav gauche ───────────────────────────────────────── */}
<aside className="account-sidebar">
<h1 className="account-title">Mon compte</h1>
{SECTIONS.map(item => (
<button
key={item.id}
className={`account-nav-item${section === item.id ? ' active' : ''}`}
onClick={() => setSection(item.id)}
>
{item.icon}
{item.label}
</button>
))}
</aside>
{/* ── Contenu ─────────────────────────────────────── */}
<div className="account-content">
{section === 'profil' && <AccountForm />}
{section === 'securite' && <SecurityForm />}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+152
View File
@@ -0,0 +1,152 @@
import { useTheme } from '../context/ThemeContext.jsx';
import { useUi } from '../context/UiContext.jsx';
/* ── Theme options ─────────────────────────────────────────── */
const THEMES = [
{
mode: 'light',
label: 'Clair',
desc: 'Interface lumineuse',
preview: (
<svg viewBox="0 0 56 36" width="56" height="36" aria-hidden="true">
<rect width="56" height="36" rx="5" fill="#f0f4ff"/>
<rect x="2" y="2" width="12" height="32" rx="3" fill="#1e3a8a"/>
<rect x="16" y="2" width="38" height="8" rx="2" fill="#ffffff" opacity=".9"/>
<rect x="16" y="12" width="38" height="5" rx="2" fill="#c7d2e8"/>
<rect x="16" y="19" width="28" height="5" rx="2" fill="#c7d2e8"/>
<rect x="16" y="26" width="20" height="5" rx="2" fill="#c7d2e8"/>
</svg>
),
},
{
mode: 'dark',
label: 'Sombre',
desc: 'Interface nocturne',
preview: (
<svg viewBox="0 0 56 36" width="56" height="36" aria-hidden="true">
<rect width="56" height="36" rx="5" fill="#060e1f"/>
<rect x="2" y="2" width="12" height="32" rx="3" fill="#0d1629"/>
<rect x="16" y="2" width="38" height="8" rx="2" fill="#111c35" opacity=".9"/>
<rect x="16" y="12" width="38" height="5" rx="2" fill="#1e3a6a"/>
<rect x="16" y="19" width="28" height="5" rx="2" fill="#1e3a6a"/>
<rect x="16" y="26" width="20" height="5" rx="2" fill="#1e3a6a"/>
</svg>
),
},
{
mode: 'system',
label: 'Système',
desc: 'Suit les préférences OS',
preview: (
<svg viewBox="0 0 56 36" width="56" height="36" aria-hidden="true">
<defs>
<linearGradient id="split" x1="0" x2="1" y1="0" y2="0">
<stop offset="50%" stopColor="#f0f4ff"/>
<stop offset="50%" stopColor="#060e1f"/>
</linearGradient>
</defs>
<rect width="56" height="36" rx="5" fill="url(#split)"/>
<rect x="2" y="2" width="12" height="32" rx="3" fill="#1e3a8a"/>
<rect x="16" y="2" width="18" height="8" rx="2" fill="#ffffff" opacity=".9"/>
<rect x="36" y="2" width="18" height="8" rx="2" fill="#111c35" opacity=".9"/>
<rect x="16" y="12" width="18" height="5" rx="2" fill="#c7d2e8"/>
<rect x="36" y="12" width="18" height="5" rx="2" fill="#1e3a6a"/>
</svg>
),
},
];
/* ── Font scale options ────────────────────────────────────── */
const FONTS = [
{
scale: 'compact',
label: 'Normal',
desc: 'Interface compacte',
sizes: { body: 12, table: 11 },
},
{
scale: 'medium',
label: 'Moyen',
desc: 'Taille intermédiaire',
sizes: { body: 13, table: 12 },
},
{
scale: 'large',
label: 'Grand',
desc: 'Meilleure lisibilité',
sizes: { body: 14, table: 13 },
},
];
/* ── Component ─────────────────────────────────────────────── */
export default function Preferences() {
const { mode, setMode } = useTheme();
const { fontScale, setFontScale } = useUi();
return (
<>
<div className="topbar">
<h2>Interface</h2>
</div>
{/* ── Thème ──────────────────────────────────────────── */}
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Apparence</h3>
<p style={{ margin: '0 0 16px', color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>
Choisissez le thème visuel de l'application.
</p>
<div className="pref-options">
{THEMES.map((t) => (
<button
key={t.mode}
type="button"
className={`pref-option${mode === t.mode ? ' active' : ''}`}
onClick={() => setMode(t.mode)}
aria-pressed={mode === t.mode}
>
{t.preview}
<span style={{ fontWeight: 700, fontSize: 'var(--fs-sm)', marginTop: 4 }}>{t.label}</span>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>{t.desc}</span>
</button>
))}
</div>
</div>
{/* ── Police ─────────────────────────────────────────── */}
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Taille du texte</h3>
<p style={{ margin: '0 0 16px', color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>
Le niveau <strong>Grand</strong> est recommandé pour les personnes malvoyantes.
Les niveaux inférieurs permettent d'afficher plus de données à l'écran.
</p>
<div className="pref-options">
{FONTS.map((f) => (
<button
key={f.scale}
type="button"
className={`pref-option${fontScale === f.scale ? ' active' : ''}`}
onClick={() => setFontScale(f.scale)}
aria-pressed={fontScale === f.scale}
>
{/* Live-size text preview */}
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4,
width: '100%', padding: '8px 0 4px',
borderBottom: '1px solid var(--border)', marginBottom: 4,
}}>
<span className="font-preview" style={{ fontSize: f.sizes.body }}>
Aa — {f.label}
</span>
<span style={{ fontSize: f.sizes.table, color: 'var(--text-muted)' }}>
Tableau {f.sizes.table}px · Corps {f.sizes.body}px
</span>
</div>
<span style={{ fontWeight: 700, fontSize: 'var(--fs-sm)', marginTop: 2 }}>{f.label}</span>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>{f.desc}</span>
</button>
))}
</div>
</div>
</>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';
export default function Register() {
const { register } = useAuth();
const navigate = useNavigate();
const [form, setForm] = useState({ email: '', password: '', displayName: '' });
const [err, setErr] = useState(null);
const [busy, setBusy] = useState(false);
const set = (k) => (e) => setForm({ ...form, [k]: e.target.value });
const submit = async (e) => {
e.preventDefault();
setErr(null); setBusy(true);
try {
await register(form.email, form.password, form.displayName || undefined);
navigate('/');
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
};
return (
<div className="login-shell">
<form className="card login-card" onSubmit={submit}>
<h2 style={{ marginTop: 0 }}>Créer un compte</h2>
{err && <div className="error">{err}</div>}
<label>Nom d'affichage</label>
<input value={form.displayName} onChange={set('displayName')} placeholder="Olivier" />
<div style={{ height: 10 }} />
<label>Email</label>
<input type="email" required value={form.email} onChange={set('email')} />
<div style={{ height: 10 }} />
<label>Mot de passe (8 car. min.)</label>
<input type="password" required minLength={8} value={form.password} onChange={set('password')} />
<div style={{ height: 16 }} />
<button className="primary" type="submit" disabled={busy} style={{ width: '100%' }}>
{busy ? '' : 'Créer le compte'}
</button>
<p className="text-muted" style={{ marginTop: 16, textAlign: 'center' }}>
Déjà inscrit ? <Link to="/login">Se connecter</Link>
</p>
</form>
</div>
);
}
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
import { useLocation, useNavigate } from 'react-router-dom';
import FamilleEntreprises from './FamilleEntreprises.jsx';
import AppearanceSection from './settings/AppearanceSection.jsx';
import PlateformesSection from './settings/PlateformesSection.jsx';
import CategoriesInvSection from './settings/CategoriesInvSection.jsx';
import SecteursInvSection from './settings/SecteursInvSection.jsx';
import ComptesSection from './settings/ComptesSection.jsx';
import MaFiscaliteSection from './settings/MaFiscaliteSection.jsx';
import DataCleanupSection from './settings/DataCleanupSection.jsx';
import ImportsSection from './settings/ImportsSection.jsx';
/* ── Icônes nav ───────────────────────────────────────────────── */
function IconFamily() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>; }
function IconMonitor() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>; }
function IconServer() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>; }
function IconWallet() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M16 12h.01M2 10h20"/></svg>; }
function IconTag() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>; }
function IconLayers() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>; }
function IconGrid() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>; }
function IconMyFiscal() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M20 7H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/><path d="M16 3H8l-2 4h12l-2-4z"/><line x1="12" y1="12" x2="12" y2="12.01"/></svg>; }
function IconBroom() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 21l9-9"/><path d="M12.22 6.22L17 1.5l5.5 5.5-4.72 4.78"/><path d="M5 17c.5-2 2-3.5 4-4.5l3.5 3.5c-1 2-2.5 3.5-4.5 4"/></svg>; }
function IconUpload() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>; }
const NAV = [
{
group: 'Interface',
items: [
{ id: 'apparence', label: 'Apparence', icon: <IconMonitor /> },
],
},
{
group: 'Mon paramétrage',
items: [
{ id: 'membres', label: 'Mes membres & entreprises', icon: <IconFamily /> },
{ id: 'plateformes', label: 'Mes plateformes', icon: <IconServer /> },
{ id: 'comptes', label: 'Mes comptes courants', icon: <IconWallet /> },
{ id: 'ma-fiscalite', label: 'Ma fiscalité', icon: <IconMyFiscal /> },
],
},
{
group: 'Mes tags',
items: [
{ id: 'categories-inv', label: "Mes catégories d'investissement", icon: <IconLayers /> },
{ id: 'secteurs-inv', label: "Mes secteurs d'investissement", icon: <IconGrid /> },
],
},
{
group: 'Mes données',
items: [
{ id: 'nettoyage', label: 'Nettoyage de données', icon: <IconBroom /> },
{ id: 'imports', label: 'Importation de données', icon: <IconUpload /> },
],
},
];
export default function Settings() {
const { search } = useLocation();
const navigate = useNavigate();
const section = new URLSearchParams(search).get('section') || 'apparence';
const setSection = (s) => navigate(`/settings?section=${s}`, { replace: true });
return (
<div className="account-layout">
<aside className="account-sidebar">
<h1 className="account-title">Gérer les paramètres</h1>
{NAV.map(group => (
<div key={group.group} className="account-nav-group">
<span className="account-nav-label">{group.group}</span>
{group.items.map(item => (
<button
key={item.id}
className={`account-nav-item${section === item.id ? ' active' : ''}`}
onClick={() => setSection(item.id)}
>
{item.icon}
{item.label}
</button>
))}
</div>
))}
</aside>
<div className="account-content">
{section === 'apparence' && <AppearanceSection />}
{section === 'membres' && <FamilleEntreprises />}
{section === 'plateformes' && <PlateformesSection />}
{section === 'comptes' && <ComptesSection />}
{section === 'ma-fiscalite' && <MaFiscaliteSection />}
{section === 'categories-inv' && <CategoriesInvSection />}
{section === 'secteurs-inv' && <SecteursInvSection />}
{section === 'nettoyage' && <DataCleanupSection />}
{section === 'imports' && <ImportsSection />}
</div>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useState } from 'react';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { fmtEUR, fmtDate } from '../utils/format.js';
export default function SimulRemboursements() {
const { activeId } = useInvestisseur();
const [investissements, setInvestissements] = useState([]);
const [selected, setSelected] = useState('');
const [echeances, setEcheances] = useState([]);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState(null);
useEffect(() => {
if (!activeId) return;
api.get('/investissements').then(setInvestissements);
}, [activeId]);
useEffect(() => {
if (!selected) { setEcheances([]); return; }
api.get('/simul', { investissement_id: selected }).then(setEcheances);
}, [selected]);
const generate = async () => {
if (!selected) return;
setBusy(true); setMsg(null);
try {
const r = await api.post('/simul/generate', {
investissement_id: Number(selected),
replace: true,
});
setMsg(`${r.inserted} échéances générées.`);
const e = await api.get('/simul', { investissement_id: selected });
setEcheances(e);
} catch (e) {
setMsg(`${e.message}`);
} finally { setBusy(false); }
};
const inv = investissements.find(i => i.id === Number(selected));
const totals = echeances.reduce((acc, e) => {
acc.capital += e.capital_prevu;
acc.interets += e.interets_prevus;
acc.total += e.total_prevu;
return acc;
}, { capital: 0, interets: 0, total: 0 });
return (
<>
<div className="topbar"><h2>Projections Remboursements</h2></div>
<div className="card">
<div className="row">
<div style={{ flex: 2 }}>
<label>Investissement</label>
<select value={selected} onChange={e => setSelected(e.target.value)}>
<option value=""> Choisir </option>
{investissements.map(i =>
<option key={i.id} value={i.id}>
{i.nom_projet} ({fmtEUR(i.montant_investi)} {i.taux_interet ?? '?'}% {i.duree_mois ?? '?'}m {i.type_remb || '?'})
</option>
)}
</select>
</div>
<div style={{ flex: 1 }}>
<button className="primary" onClick={generate} disabled={!selected || busy} style={{ width: '100%' }}>
{busy ? '…' : 'Générer / Régénérer'}
</button>
</div>
</div>
{msg && <div className={msg.startsWith('✔') ? 'success-msg' : 'error'} style={{ marginTop: 12 }}>{msg}</div>}
{inv && (!inv.taux_interet || !inv.duree_mois) && (
<div className="error" style={{ marginTop: 12 }}>
Cet investissement n'a pas de <strong>taux</strong> et/ou de <strong>durée</strong>. Renseignez-les dans la fiche.
</div>
)}
</div>
{echeances.length > 0 && (
<div className="card">
<div className="kpi-grid" style={{ marginBottom: 12 }}>
<div className="kpi"><div className="label">Capital prévu</div><div className="value">{fmtEUR(totals.capital)}</div></div>
<div className="kpi"><div className="label">Intérêts prévus</div><div className="value success">{fmtEUR(totals.interets)}</div></div>
<div className="kpi"><div className="label">Total prévu</div><div className="value">{fmtEUR(totals.total)}</div></div>
</div>
<table>
<thead>
<tr>
<th>#</th><th>Date prévue</th>
<th className="num">Capital</th><th className="num">Intérêts</th>
<th className="num">Total échéance</th>
</tr>
</thead>
<tbody>
{echeances.map(e => (
<tr key={e.id}>
<td>{e.numero_echeance}</td>
<td>{fmtDate(e.date_prevue)}</td>
<td className="num">{fmtEUR(e.capital_prevu)}</td>
<td className="num">{fmtEUR(e.interets_prevus)}</td>
<td className="num">{fmtEUR(e.total_prevu)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
);
}
+305
View File
@@ -0,0 +1,305 @@
import { useEffect, useRef, useState } from 'react';
import PageIcon from '../components/PageIcon.jsx';
import Pagination from '../components/Pagination.jsx';
import { usePagination } from '../hooks/usePagination.js';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useUi } from '../context/UiContext.jsx';
import { fmtEUR, fmtStatut } from '../utils/format.js';
import Cerfa2561Preview from '../components/Cerfa2561Preview.jsx';
import CerfaRecapTable from '../components/CerfaRecapTable.jsx';
import Cerfa2778Preview from '../components/Cerfa2778Preview.jsx';
import Cerfa2042Preview from '../components/Cerfa2042Preview.jsx';
/* ── YearSelector ────────────────────────────────────────────── */
function YearSelector({ annee, setAnnee, availableYears }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
return (
<div ref={ref} style={{ position: 'relative', flexShrink: 0, width: 160 }}>
<div
onClick={() => setOpen(v => !v)}
style={{
height: '100%', boxSizing: 'border-box',
background: 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)',
borderRadius: 10,
padding: '12px 16px',
boxShadow: open
? '0 6px 28px rgba(109,40,217,0.45)'
: '0 4px 20px rgba(109,40,217,0.30)',
cursor: 'pointer',
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
userSelect: 'none',
transition: 'box-shadow .15s',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{
fontSize: 'var(--fs-xs)', textTransform: 'uppercase',
letterSpacing: '.06em', color: 'rgba(255,255,255,0.7)', fontWeight: 500,
}}>Année</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="rgba(255,255,255,0.7)" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<div style={{ color: '#fff', fontSize: '1.8rem', fontWeight: 700, lineHeight: 1.1, marginTop: 6 }}>
{annee}
</div>
</div>
{open && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 200,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 10, boxShadow: '0 8px 28px rgba(0,0,0,0.15)',
minWidth: 160, overflow: 'hidden',
}}>
{availableYears.map((yr, i) => {
const isActive = String(yr) === String(annee);
return (
<div
key={yr}
onClick={() => { setAnnee(String(yr)); setOpen(false); }}
style={{
padding: '10px 16px', cursor: 'pointer',
background: isActive ? 'rgba(109,40,217,0.08)' : 'transparent',
color: isActive ? '#7c3aed' : 'var(--text)',
fontWeight: isActive ? 700 : 400,
fontSize: 'var(--fs-sm)',
borderBottom: i < availableYears.length - 1 ? '1px solid var(--border)' : 'none',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
transition: 'background .1s',
}}
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--surface-2)'; }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
>
<span>{yr}</span>
{isActive && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="#7c3aed" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
</div>
);
})}
</div>
)}
</div>
);
}
/* ── ExportDropdown ──────────────────────────────────────────── */
function ExportDropdown({ disabled, onCSV, onJSON }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
const choose = fn => { setOpen(false); fn(); };
return (
<div ref={ref} style={{ position: 'relative' }}>
<button type="button" className="icon-btn" disabled={disabled}
onClick={() => setOpen(o => !o)} title="Exporter">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
{open && (
<div className="export-dropdown" role="menu">
<button role="menuitem" onClick={() => choose(onCSV)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
<line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/>
</svg>
<span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
</button>
<button role="menuitem" onClick={() => choose(onJSON)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
<path d="M8 13h1.5a1 1 0 0 1 1 1v1a1 1 0 0 0 1 1 1 1 0 0 0-1 1v1a1 1 0 0 1-1 1H8"/>
<path d="M16 13h-1.5a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1H16"/>
</svg>
<span><strong>Format JSON</strong><small>Réimportable, structuré</small></span>
</button>
</div>
)}
</div>
);
}
/* ── Composant principal ─────────────────────────────────────── */
export default function TaxReport() {
const { activeId, activeView } = useInvestisseur();
const { pfoAssujetti } = useUi();
const [annee, setAnnee] = useState(String(new Date().getFullYear()));
const [availableYears, setAvailableYears] = useState([]);
const [data, setData] = useState(null);
const [data2042, setData2042] = useState(null);
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState('2042');
const [detailExpanded, setDetailExpanded] = useState(false);
const [cerfaExpanded, setCerfaExpanded] = useState(false);
/* ── Chargement des années disponibles ── */
useEffect(() => {
if (!activeId && activeView !== 'all') return;
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
api.get('/taxreport/years', scopeParams).then(years => {
setAvailableYears(years);
// Si l'année courante n'est pas dans la liste, prendre la première disponible
if (years.length > 0 && !years.map(String).includes(annee)) {
setAnnee(String(years[0]));
}
});
}, [activeId, activeView]); // eslint-disable-line
const load = () => {
if (!activeId && activeView !== 'all') return;
setData(null);
setLoading(true);
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
const LS_EXCL = 'cl_2778_excluded_plats';
const excluded = new Set(JSON.parse(localStorage.getItem(LS_EXCL) ?? '[]'));
Promise.all([
api.get('/taxreport', { annee, ...scopeParams }),
api.get('/taxreport/cerfa2561', { annee, ...scopeParams }),
api.get('/taxreport/2778', { annee, ...scopeParams }),
]).then(([d, d2561, d2778]) => {
setData(d);
const frLignes = (d2561?.lignes ?? []).filter(l => l.domiciliation === 'FR');
const platEtr = (d2778?.plateformes ?? []).filter(p => !excluded.has(p.id));
const etrBA = p => Object.values(p.mois ?? {}).reduce((s, v) => s + v, 0);
const pfo = 0.128; // taux par défaut — affiné par pfuList si dispo
setData2042({
case_2TT: frLignes.reduce((s, l) => s + (l.case_2TT ?? 0), 0),
case_2TR: frLignes.reduce((s, l) => s + (l.case_2TR ?? 0), 0) + Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0)),
case_2BH: frLignes.reduce((s, l) => s + (l.case_2BH ?? 0), 0) + Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0)),
case_2CK: frLignes.reduce((s, l) => s + (l.case_2CK ?? 0), 0) + Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0) * pfo),
case_2TY: frLignes.reduce((s, l) => s + (l.case_2TY ?? 0), 0),
});
}).finally(() => setLoading(false));
};
useEffect(load, [activeId, activeView, annee]); // eslint-disable-line
/* ── Pagination détail ── */
const detail = data?.detail ?? [];
const {
pagedItems: pagedDetail, page: detPage, setPage: setDetPage,
pageSize: detPageSize, setPageSize: setDetPageSize,
totalPages: detTotalPages, totalItems: detTotalItems, PAGE_SIZES,
} = usePagination(detail, 'cl_pagesize_fiscal_detail', [activeTab]);
/* ── Exports ── */
const dlBlob = (content, filename, type) => {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
};
const downloadCsv = () => {
const token = localStorage.getItem('cl_token');
const investisseurId = localStorage.getItem('cl_investisseur_id');
const exportParams = activeView === 'all' ? { annee, scope: 'all' } : { annee };
fetch(api.exportUrl('/taxreport/export', exportParams), {
headers: {
Authorization: `Bearer ${token}`,
'X-Investisseur-Id': investisseurId,
},
})
.then(r => r.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `2778-SD-${annee}.csv`;
a.click();
URL.revokeObjectURL(url);
});
};
const downloadJson = () => {
if (!data) return;
const payload = {
annee: data.annee,
recap: data.recap,
cases: data.cases,
detail: data.detail,
pertes: data.pertes,
pertesTotales: data.pertesTotales,
};
dlBlob(JSON.stringify(payload, null, 2), `2778-SD-${annee}.json`, 'application/json');
};
return (
<>
<div className="topbar">
<h2><PageIcon name="tax" />Fiscalité Récapitulatif fiscal</h2>
<YearSelector annee={annee} setAnnee={setAnnee} availableYears={availableYears} />
</div>
{loading || !data ? (
<div className="card text-muted">Chargement</div>
) : (
<>
{!detailExpanded && !cerfaExpanded && data2042 && <div className="card">
<h3 style={{ marginTop: 0 }}>Cases fiscales 2042 synthèse {annee}</h3>
<div className="kpi-grid">
{data2042.case_2TT > 0 && <div className="kpi"><div className="label">Case 2TT Prêts participatifs (FR)</div><div className="value">{fmtEUR(data2042.case_2TT)}</div></div>}
{data2042.case_2TR > 0 && <div className="kpi"><div className="label">Case 2TR Revenus fixes (FR + étranger)</div><div className="value">{fmtEUR(data2042.case_2TR)}</div></div>}
<div className="kpi"><div className="label">Case 2BH Base CSG/CRDS</div><div className="value">{fmtEUR(data2042.case_2BH)}</div></div>
<div className="kpi"><div className="label">Case 2CK Crédit d'impôt</div><div className="value" style={{ color: 'var(--success)' }}>{fmtEUR(data2042.case_2CK)}</div></div>
{data2042.case_2TY > 0 && <div className="kpi"><div className="label">Case 2TY — Pertes en capital</div><div className="value danger">{fmtEUR(data2042.case_2TY)}</div></div>}
</div>
<p className="text-muted" style={{ marginTop: 12, fontSize: 12 }}>
⚠ Cases indicatives combinant plateformes françaises (IFU automatique) et étrangères. Référez-vous à la notice 2041-GFI.
</p>
</div>}
{!detailExpanded && !cerfaExpanded && (
<div className="dr-tabs">
<button className={`dr-tab${activeTab === '2042' ? ' active' : ''}`} onClick={() => setActiveTab('2042')}>CERFA 2042</button>
<button className={`dr-tab${activeTab === 'cerfa' ? ' active' : ''}`} onClick={() => setActiveTab('cerfa')}>CERFA 2561 (IFU)</button>
{pfoAssujetti && <button className={`dr-tab${activeTab === '2778' ? ' active' : ''}`} onClick={() => setActiveTab('2778')}>CERFA 2778-SD</button>}
</div>
)}
</>
)}
{activeTab === '2042' && !detailExpanded && !cerfaExpanded && data && (
<Cerfa2042Preview annee={annee} activeView={activeView} pfoAssujetti={pfoAssujetti} />
)}
{(activeTab === 'cerfa' || cerfaExpanded) && !detailExpanded && data && (
<Cerfa2561Preview
annee={annee} activeView={activeView} inline
expanded={cerfaExpanded}
onToggleExpand={() => { setCerfaExpanded(e => !e); setActiveTab('cerfa'); }}
/>
)}
{activeTab === '2778' && pfoAssujetti && !detailExpanded && !cerfaExpanded && (
<Cerfa2778Preview annee={annee} activeView={activeView} />
)}
</>
);
}
@@ -0,0 +1,69 @@
import { useState } from 'react';
import { api } from '../../api.js';
export default function CreateUserSection({ onCreated }) {
const [form, setForm] = useState({ email: '', password: '', displayName: '', role: 'user' });
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(null);
const [err, setErr] = useState(null);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true); setErr(null); setSuccess(null);
try {
const created = await api.post('/admin/users', {
email: form.email,
password: form.password,
displayName: form.displayName || undefined,
role: form.role,
});
setSuccess(`Utilisateur "${created.display_name || created.email}" créé avec succès.`);
setForm({ email: '', password: '', displayName: '', role: 'user' });
onCreated?.();
} catch (e) { setErr(e.message); }
finally { setLoading(false); }
};
return (
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Créer un utilisateur</h3>
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
Créez un nouveau compte manuellement sur la plateforme.
</p>
{success && <div className="success-msg" style={{ marginBottom: 16 }}>{success}</div>}
{err && <div className="error" style={{ marginBottom: 16 }}>{err}</div>}
<form onSubmit={handleSubmit} style={{ maxWidth: 480 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label>Nom affiché</label>
<input value={form.displayName} onChange={e => set('displayName', e.target.value)} placeholder="Prénom Nom" />
</div>
<div>
<label>Email *</label>
<input type="email" required value={form.email} onChange={e => set('email', e.target.value)} placeholder="utilisateur@exemple.com" />
</div>
<div>
<label>Mot de passe *</label>
<input type="password" required minLength={8} value={form.password} onChange={e => set('password', e.target.value)} placeholder="8 caractères minimum" />
</div>
<div>
<label>Rôle</label>
<select value={form.role} onChange={e => set('role', e.target.value)}>
<option value="user">Utilisateur</option>
<option value="admin">Administrateur</option>
</select>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 20 }}>
<button type="submit" className="primary" disabled={loading}>
{loading ? 'Création…' : 'Créer l\'utilisateur'}
</button>
</div>
</form>
</div>
);
}
+240
View File
@@ -0,0 +1,240 @@
import { useState, useEffect, useCallback } from 'react';
import { api } from '../../api.js';
const ICONS_BASE = '/api/icons-files/';
function IconPlus() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>;
}
function IconUpload() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>;
}
function IconDownload() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>;
}
function IconHistory() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.95"/></svg>;
}
export default function IconsSection() {
const [icons, setIcons] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [uploading, setUploading] = useState(null);
const [history, setHistory] = useState(null); // { name, rows } | null
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState('');
const [newFile, setNewFile] = useState(null);
const [createErr, setCreateErr] = useState(null);
const [createOk, setCreateOk] = useState(null);
const load = useCallback(async () => {
setLoading(true);
try { setIcons(await api.get('/icons')); }
catch { setErr('Erreur de chargement'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
async function handleReplace(name) {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.svg,.png,.jpg,.jpeg,.webp';
input.onchange = async () => {
const file = input.files[0];
if (!file) return;
setUploading(name);
try {
const fd = new FormData();
fd.append('file', file);
const token = localStorage.getItem('cl_token');
const res = await fetch(`/api/icons/${name}`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}` },
body: fd,
});
if (!res.ok) { const j = await res.json(); throw new Error(j.error); }
await load();
} catch (e) { setErr(e.message); }
finally { setUploading(null); }
};
input.click();
}
async function loadHistory(name) {
try {
const rows = await api.get(`/icons/${name}/history`);
setHistory({ name, rows });
} catch { setErr('Erreur historique'); }
}
async function handleCreate(e) {
e.preventDefault();
setCreateErr(null); setCreateOk(null);
if (!newName.trim()) return setCreateErr('Nom requis');
if (!newFile) return setCreateErr('Fichier requis');
try {
const fd = new FormData();
fd.append('name', newName.trim().toLowerCase());
fd.append('description', newDesc.trim());
fd.append('file', newFile);
const token = localStorage.getItem('cl_token');
const res = await fetch('/api/icons', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: fd,
});
const json = await res.json();
if (!res.ok) throw new Error(json.error);
setCreateOk(`Icône "${json.name}" créée.`);
setNewName(''); setNewDesc(''); setNewFile(null);
setCreating(false);
await load();
} catch (e) { setCreateErr(e.message); }
}
if (loading) return <p className="text-muted" style={{ padding: 24 }}>Chargement</p>;
return (
<div>
<div className="topbar" style={{ marginBottom: 20 }}>
<div>
<h2 style={{ margin: 0 }}>Bibliothèque d'icônes</h2>
<p className="text-muted" style={{ margin: '4px 0 0', fontSize: 'var(--fs-sm)' }}>
{icons.length} icône{icons.length !== 1 ? 's' : ''} — les noms sont les clés utilisées par l'application.
</p>
</div>
<button className="btn btn-primary" onClick={() => { setCreating(v => !v); setCreateErr(null); setCreateOk(null); }}>
<IconPlus /> Nouvelle icône
</button>
</div>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
{creating && (
<div className="card" style={{ marginBottom: 20 }}>
<h3 style={{ margin: '0 0 14px' }}>Nouvelle association nom / image</h3>
<form onSubmit={handleCreate}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
<div>
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>
Nom (slug) *
</label>
<input
className="form-input"
placeholder="ex: taux-defaut"
value={newName}
onChange={e => setNewName(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
/>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>lettres minuscules, chiffres, tirets</span>
</div>
<div>
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>
Description
</label>
<input
className="form-input"
placeholder="ex: Taux de défaut"
value={newDesc}
onChange={e => setNewDesc(e.target.value)}
/>
</div>
</div>
<div style={{ marginBottom: 14 }}>
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>
Fichier image * (SVG, PNG, JPG, WebP 2 Mo max)
</label>
<input
type="file"
accept=".svg,.png,.jpg,.jpeg,.webp"
onChange={e => setNewFile(e.target.files[0] || null)}
/>
{newFile && <span style={{ marginLeft: 8, fontSize: 12, color: 'var(--text-muted)' }}>{newFile.name}</span>}
</div>
{createErr && <div className="error" style={{ marginBottom: 8 }}>{createErr}</div>}
{createOk && <div className="success" style={{ marginBottom: 8 }}>{createOk}</div>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary">Créer</button>
<button type="button" className="btn btn-secondary" onClick={() => setCreating(false)}>Annuler</button>
</div>
</form>
</div>
)}
<div className="icons-grid">
{icons.map(icon => (
<div key={icon.name} className="icon-card">
<div className="icon-card-preview">
<img
src={`${ICONS_BASE}${icon.filename}`}
alt={icon.name}
style={{ width: 48, height: 48, objectFit: 'contain' }}
/>
</div>
<div className="icon-card-body">
<span className="icon-card-name">{icon.name}</span>
{icon.description && (
<span className="icon-card-desc">{icon.description}</span>
)}
<span className="icon-card-file">{icon.filename}</span>
</div>
<div className="icon-card-actions">
<button
className="btn btn-sm btn-secondary"
onClick={() => handleReplace(icon.name)}
disabled={uploading === icon.name}
title="Remplacer l'image"
>
{uploading === icon.name ? '…' : <><IconUpload /> Remplacer</>}
</button>
<a
className="btn btn-sm btn-ghost"
href={`${ICONS_BASE}${icon.filename}`}
download={icon.filename}
title="Télécharger le fichier nettoyé"
>
<IconDownload />
</a>
<button
className="btn btn-sm btn-ghost"
onClick={() => history?.name === icon.name ? setHistory(null) : loadHistory(icon.name)}
title="Historique des versions"
>
<IconHistory />
</button>
</div>
{history?.name === icon.name && (
<div className="icon-history">
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)' }}>
Historique ({history.rows.length} version{history.rows.length !== 1 ? 's' : ''})
</span>
{history.rows.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>Aucune version précédente</span>
) : (
<div className="icon-history-list">
{history.rows.map(h => (
<div key={h.id} className="icon-history-row">
<img
src={`${ICONS_BASE}${h.filename}`}
alt="prev"
style={{ width: 28, height: 28, objectFit: 'contain', opacity: .7 }}
/>
<span style={{ fontSize: 11, color: 'var(--text-muted)', flex: 1 }}>{h.filename}</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{new Date(h.replaced_at).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { useState, useEffect, useCallback } from 'react';
import { api } from '../../api.js';
import { fmt, StatusBadge } from './adminHelpers.jsx';
const KNOWN_JOBS = [
{ name: 'auto_statut_retard', label: 'Passage automatique en retard' },
];
export default function JobLogsSection() {
const [logs, setLogs] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState(null);
const [runResult, setRunResult] = useState(null);
const [err, setErr] = useState(null);
const [page, setPage] = useState(0);
const PER_PAGE = 20;
const load = useCallback(async () => {
try {
setLoading(true);
const data = await api.get('/admin/job-logs', { limit: PER_PAGE, offset: page * PER_PAGE });
setLogs(data.rows);
setTotal(data.total);
} catch (e) { setErr(e.message); }
finally { setLoading(false); }
}, [page]);
useEffect(() => { load(); }, [load]);
const runJob = async (jobName) => {
setRunning(jobName); setRunResult(null);
try {
const r = await api.post(`/admin/jobs/${jobName}/run`, {});
setRunResult({ ok: true, msg: `Exécution terminée — ${r.nb_changes} modification(s)` });
load();
} catch (e) {
setRunResult({ ok: false, msg: e.message });
} finally { setRunning(null); }
};
const pages = Math.ceil(total / PER_PAGE);
return (
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Logs des jobs automatiques</h3>
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
Historique d'exécution des tâches planifiées et lancement manuel.
</p>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', marginBottom: 20, flexWrap: 'wrap' }}>
{KNOWN_JOBS.map(j => (
<button
key={j.name}
className="btn btn-outline"
style={{ display: 'flex', alignItems: 'center', gap: 7 }}
disabled={running === j.name}
onClick={() => runJob(j.name)}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polygon points="4,2 14,8 4,14"/>
</svg>
{running === j.name ? 'Exécution' : `Lancer : ${j.label}`}
</button>
))}
{runResult && (
<span style={{
fontSize: 13, padding: '4px 12px', borderRadius: 6,
background: runResult.ok ? 'rgba(34,197,94,.1)' : 'rgba(239,68,68,.1)',
color: runResult.ok ? '#16a34a' : '#dc2626',
border: `1px solid ${runResult.ok ? 'rgba(34,197,94,.3)' : 'rgba(239,68,68,.3)'}`,
}}>
{runResult.msg}
</span>
)}
</div>
{loading && <p style={{ color: 'var(--text-muted)' }}>Chargement…</p>}
{err && <p style={{ color: '#ef4444' }}>{err}</p>}
{!loading && !err && !logs.length && <p style={{ color: 'var(--text-muted)' }}>Aucun log disponible.</p>}
{logs.length > 0 && (
<>
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 12 }}>
{total} entrée{total > 1 ? 's' : ''} au total
</p>
<table style={{ fontSize: 13 }}>
<thead>
<tr>
<th>Date</th><th>Job</th><th>Statut</th>
<th className="num">Modifs</th><th>Détails</th><th>Erreur</th>
</tr>
</thead>
<tbody>
{logs.map(l => (
<tr key={l.id}>
<td style={{ whiteSpace: 'nowrap', color: 'var(--text-muted)' }}>{fmt(l.run_at)}</td>
<td style={{ fontFamily: 'monospace', fontSize: 12 }}>{l.job_name}</td>
<td><StatusBadge status={l.status} /></td>
<td className="num">
{l.nb_changes > 0
? <span style={{ fontWeight: 700, color: '#f97316' }}>{l.nb_changes}</span>
: <span style={{ color: 'var(--text-muted)' }}>0</span>
}
</td>
<td style={{ maxWidth: 280, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={l.details || ''}>
{l.details || <span style={{ color: 'var(--text-muted)' }}>—</span>}
</td>
<td style={{ color: '#ef4444', fontSize: 12 }}>
{l.error_msg || <span style={{ color: 'var(--text-muted)' }}>—</span>}
</td>
</tr>
))}
</tbody>
</table>
{pages > 1 && (
<div style={{ display: 'flex', gap: 8, marginTop: 16, alignItems: 'center' }}>
<button className="btn btn-sm btn-outline" disabled={page === 0} onClick={() => setPage(p => p - 1)}>← Précédent</button>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>Page {page + 1} / {pages}</span>
<button className="btn btn-sm btn-outline" disabled={page >= pages - 1} onClick={() => setPage(p => p + 1)}>Suivant </button>
</div>
)}
</>
)}
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
import { useState, useEffect, useCallback } from 'react';
import { api } from '../../api.js';
import ConfirmModal from '../../components/ConfirmModal.jsx';
import { fmt, Badge } from './adminHelpers.jsx';
export default function UsersSection({ currentUserId }) {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [confirmAction, setConfirmAction] = useState(null);
const load = useCallback(async () => {
try {
setLoading(true);
const data = await api.get('/admin/users');
setUsers(data);
} catch (e) { setErr(e.message); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const toggleRole = (u) => {
const newRole = u.role === 'admin' ? 'user' : 'admin';
setConfirmAction({
title: 'Changer le rôle',
message: `Changer le rôle de ${u.display_name || u.email}${newRole === 'admin' ? 'Administrateur' : 'Utilisateur'} ?`,
confirmLabel: 'Confirmer',
onConfirm: async () => {
try {
await api.patch(`/admin/users/${u.id}/role`, { role: newRole });
load();
} catch (e) { setErr('Erreur : ' + e.message); }
finally { setConfirmAction(null); }
},
});
};
const deleteUser = (u) => {
setConfirmAction({
title: 'Supprimer l\'utilisateur',
message: `Supprimer définitivement ${u.display_name || u.email} ? Toutes ses données seront effacées.`,
onConfirm: async () => {
try {
await api.del(`/admin/users/${u.id}`);
load();
} catch (e) { setErr('Erreur : ' + e.message); }
finally { setConfirmAction(null); }
},
});
};
if (loading) return <p style={{ color: 'var(--text-muted)' }}>Chargement</p>;
if (err) return <p style={{ color: '#ef4444' }}>{err}</p>;
return (
<>
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Comptes utilisateurs</h3>
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
{users.length} utilisateur{users.length !== 1 ? 's' : ''} enregistré{users.length !== 1 ? 's' : ''}
</p>
<table>
<thead>
<tr>
<th style={{ width: 36 }}>ID</th>
<th>Nom</th>
<th>Email</th>
<th>Rôle</th>
<th>Créé le</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
<td style={{ color: 'var(--text-muted)' }}>{u.id}</td>
<td style={{ fontWeight: 500 }}>{u.display_name || <em style={{ color: 'var(--text-muted)' }}></em>}</td>
<td>{u.email}</td>
<td><Badge role={u.role} /></td>
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmt(u.created_at)}</td>
<td>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn btn-sm btn-outline"
onClick={() => toggleRole(u)}
disabled={u.id === currentUserId && u.role === 'admin'}
title={u.id === currentUserId ? 'Vous ne pouvez pas vous rétrograder' : ''}
>
{u.role === 'admin' ? '→ Utilisateur' : '→ Admin'}
</button>
{u.id !== currentUserId && (
<button className="btn btn-sm btn-danger" onClick={() => deleteUser(u)}>
Supprimer
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<ConfirmModal
open={!!confirmAction}
title={confirmAction?.title}
message={confirmAction?.message}
confirmLabel={confirmAction?.confirmLabel}
onConfirm={confirmAction?.onConfirm}
onCancel={() => setConfirmAction(null)}
/>
</>
);
}
+41
View File
@@ -0,0 +1,41 @@
/* ── Helpers partagés Admin ───────────────────────────────────── */
export function fmt(iso) {
if (!iso) return '—';
const utc = iso.includes('T') || iso.endsWith('Z')
? iso
: iso.replace(' ', 'T') + 'Z';
return new Date(utc).toLocaleString('fr-FR', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
export function Badge({ role }) {
const isAdmin = role === 'admin';
return (
<span style={{
display: 'inline-block', padding: '2px 10px', borderRadius: 12,
fontSize: 11, fontWeight: 700, letterSpacing: '.04em',
background: isAdmin ? 'rgba(234,179,8,.15)' : 'rgba(100,116,139,.15)',
color: isAdmin ? '#ca8a04' : '#64748b',
border: `1px solid ${isAdmin ? 'rgba(234,179,8,.35)' : 'rgba(100,116,139,.25)'}`,
}}>
{isAdmin ? 'Admin' : 'Utilisateur'}
</span>
);
}
export function StatusBadge({ status }) {
const ok = status === 'ok';
return (
<span style={{
display: 'inline-block', padding: '2px 10px', borderRadius: 12,
fontSize: 11, fontWeight: 700,
background: ok ? 'rgba(34,197,94,.12)' : 'rgba(239,68,68,.12)',
color: ok ? '#16a34a' : '#dc2626',
border: `1px solid ${ok ? 'rgba(34,197,94,.3)' : 'rgba(239,68,68,.3)'}`,
}}>
{ok ? 'OK' : 'Erreur'}
</span>
);
}
@@ -0,0 +1,343 @@
import { useState, useEffect } from 'react';
import { useTheme } from '../../context/ThemeContext.jsx';
import { useUi } from '../../context/UiContext.jsx';
import { api } from '../../api.js';
/* ── Apparence — données ─────────────────────────────────────── */
const THEMES = [
{
mode: 'light', label: 'Clair', desc: 'Interface lumineuse',
preview: (
<svg viewBox="0 0 56 36" width="56" height="36" aria-hidden="true">
<rect width="56" height="36" rx="5" fill="#f0f4ff"/>
<rect x="2" y="2" width="12" height="32" rx="3" fill="#1e3a8a"/>
<rect x="16" y="2" width="38" height="8" rx="2" fill="#ffffff" opacity=".9"/>
<rect x="16" y="12" width="38" height="5" rx="2" fill="#c7d2e8"/>
<rect x="16" y="19" width="28" height="5" rx="2" fill="#c7d2e8"/>
<rect x="16" y="26" width="20" height="5" rx="2" fill="#c7d2e8"/>
</svg>
),
},
{
mode: 'dark', label: 'Sombre', desc: 'Interface nocturne',
preview: (
<svg viewBox="0 0 56 36" width="56" height="36" aria-hidden="true">
<rect width="56" height="36" rx="5" fill="#060e1f"/>
<rect x="2" y="2" width="12" height="32" rx="3" fill="#0d1629"/>
<rect x="16" y="2" width="38" height="8" rx="2" fill="#111c35" opacity=".9"/>
<rect x="16" y="12" width="38" height="5" rx="2" fill="#1e3a6a"/>
<rect x="16" y="19" width="28" height="5" rx="2" fill="#1e3a6a"/>
<rect x="16" y="26" width="20" height="5" rx="2" fill="#1e3a6a"/>
</svg>
),
},
{
mode: 'system', label: 'Système', desc: 'Suit les préférences OS',
preview: (
<svg viewBox="0 0 56 36" width="56" height="36" aria-hidden="true">
<defs>
<linearGradient id="split" x1="0" x2="1" y1="0" y2="0">
<stop offset="50%" stopColor="#f0f4ff"/>
<stop offset="50%" stopColor="#060e1f"/>
</linearGradient>
</defs>
<rect width="56" height="36" rx="5" fill="url(#split)"/>
<rect x="2" y="2" width="12" height="32" rx="3" fill="#1e3a8a"/>
<rect x="16" y="2" width="18" height="8" rx="2" fill="#ffffff" opacity=".9"/>
<rect x="36" y="2" width="18" height="8" rx="2" fill="#111c35" opacity=".9"/>
<rect x="16" y="12" width="18" height="5" rx="2" fill="#c7d2e8"/>
<rect x="36" y="12" width="18" height="5" rx="2" fill="#1e3a6a"/>
</svg>
),
},
];
const FONTS = [
{ scale: 'compact', label: 'Normal', desc: 'Interface compacte', sizes: { body: 12, table: 11 } },
{ scale: 'medium', label: 'Moyen', desc: 'Taille intermédiaire', sizes: { body: 13, table: 12 } },
{ scale: 'large', label: 'Grand', desc: 'Meilleure lisibilité', sizes: { body: 14, table: 13 } },
];
/* ── Icônes graphiques (couleurs) ────────────────────────────── */
const ICONS_BASE = '/api/icons-files/';
function AppIcon({ filename, size = 22 }) {
if (!filename) return null;
return (
<img
src={`${ICONS_BASE}${filename}`}
alt=""
width={size}
height={size}
className="app-lib-icon"
aria-hidden="true"
/>
);
}
/* ── Palette Material Design 2 ───────────────────────────────── */
const MD_PALETTE = {
'Rouge': { base: '#f44336', shades: { '50':'#ffebee','100':'#ffcdd2','200':'#ef9a9a','300':'#e57373','400':'#ef5350','500':'#f44336','600':'#e53935','700':'#d32f2f','800':'#c62828','900':'#b71c1c','A100':'#ff8a80','A200':'#ff5252','A400':'#ff1744','A700':'#d50000' } },
'Rose': { base: '#e91e63', shades: { '50':'#fce4ec','100':'#f8bbd0','200':'#f48fb1','300':'#f06292','400':'#ec407a','500':'#e91e63','600':'#d81b60','700':'#c2185b','800':'#ad1457','900':'#880e4f','A100':'#ff80ab','A200':'#ff4081','A400':'#f50057','A700':'#c51162' } },
'Violet': { base: '#9c27b0', shades: { '50':'#f3e5f5','100':'#e1bee7','200':'#ce93d8','300':'#ba68c8','400':'#ab47bc','500':'#9c27b0','600':'#8e24aa','700':'#7b1fa2','800':'#6a1b9a','900':'#4a148c','A100':'#ea80fc','A200':'#e040fb','A400':'#d500f9','A700':'#aa00ff' } },
'Violet foncé': { base: '#673ab7', shades: { '50':'#ede7f6','100':'#d1c4e9','200':'#b39ddb','300':'#9575cd','400':'#7e57c2','500':'#673ab7','600':'#5e35b1','700':'#512da8','800':'#4527a0','900':'#311b92','A100':'#b388ff','A200':'#7c4dff','A400':'#651fff','A700':'#6200ea' } },
'Indigo': { base: '#3f51b5', shades: { '50':'#e8eaf6','100':'#c5cae9','200':'#9fa8da','300':'#7986cb','400':'#5c6bc0','500':'#3f51b5','600':'#3949ab','700':'#303f9f','800':'#283593','900':'#1a237e','A100':'#8c9eff','A200':'#536dfe','A400':'#3d5afe','A700':'#304ffe' } },
'Bleu': { base: '#2196f3', shades: { '50':'#e3f2fd','100':'#bbdefb','200':'#90caf9','300':'#64b5f6','400':'#42a5f5','500':'#2196f3','600':'#1e88e5','700':'#1976d2','800':'#1565c0','900':'#0d47a1','A100':'#82b1ff','A200':'#448aff','A400':'#2979ff','A700':'#2962ff' } },
'Bleu clair': { base: '#03a9f4', shades: { '50':'#e1f5fe','100':'#b3e5fc','200':'#81d4fa','300':'#4fc3f7','400':'#29b6f6','500':'#03a9f4','600':'#039be5','700':'#0288d1','800':'#0277bd','900':'#01579b','A100':'#80d8ff','A200':'#40c4ff','A400':'#00b0ff','A700':'#0091ea' } },
'Cyan': { base: '#00bcd4', shades: { '50':'#e0f7fa','100':'#b2ebf2','200':'#80deea','300':'#4dd0e1','400':'#26c6da','500':'#00bcd4','600':'#00acc1','700':'#0097a7','800':'#00838f','900':'#006064','A100':'#84ffff','A200':'#18ffff','A400':'#00e5ff','A700':'#00b8d4' } },
'Sarcelle': { base: '#009688', shades: { '50':'#e0f2f1','100':'#b2dfdb','200':'#80cbc4','300':'#4db6ac','400':'#26a69a','500':'#009688','600':'#00897b','700':'#00796b','800':'#00695c','900':'#004d40','A100':'#a7ffeb','A200':'#64ffda','A400':'#1de9b6','A700':'#00bfa5' } },
'Vert': { base: '#4caf50', shades: { '50':'#e8f5e9','100':'#c8e6c9','200':'#a5d6a7','300':'#81c784','400':'#66bb6a','500':'#4caf50','600':'#43a047','700':'#388e3c','800':'#2e7d32','900':'#1b5e20','A100':'#b9f6ca','A200':'#69f0ae','A400':'#00e676','A700':'#00c853' } },
'Vert clair': { base: '#8bc34a', shades: { '50':'#f1f8e9','100':'#dcedc8','200':'#c5e1a5','300':'#aed581','400':'#9ccc65','500':'#8bc34a','600':'#7cb342','700':'#689f38','800':'#558b2f','900':'#33691e','A100':'#ccff90','A200':'#b2ff59','A400':'#76ff03','A700':'#64dd17' } },
'Citron vert': { base: '#cddc39', shades: { '50':'#f9fbe7','100':'#f0f4c3','200':'#e6ee9c','300':'#dce775','400':'#d4e157','500':'#cddc39','600':'#c0ca33','700':'#afb42b','800':'#9e9d24','900':'#827717','A100':'#f4ff81','A200':'#eeff41','A400':'#c6ff00','A700':'#aeea00' } },
'Jaune': { base: '#ffeb3b', shades: { '50':'#fffde7','100':'#fff9c4','200':'#fff59d','300':'#fff176','400':'#ffee58','500':'#ffeb3b','600':'#fdd835','700':'#fbc02d','800':'#f9a825','900':'#f57f17','A100':'#ffff8d','A200':'#ffff00','A400':'#ffea00','A700':'#ffd600' } },
'Ambre': { base: '#ffc107', shades: { '50':'#fff8e1','100':'#ffecb3','200':'#ffe082','300':'#ffd54f','400':'#ffca28','500':'#ffc107','600':'#ffb300','700':'#ffa000','800':'#ff8f00','900':'#ff6f00','A100':'#ffe57f','A200':'#ffd740','A400':'#ffc400','A700':'#ffab00' } },
'Orange': { base: '#ff9800', shades: { '50':'#fff3e0','100':'#ffe0b2','200':'#ffcc80','300':'#ffb74d','400':'#ffa726','500':'#ff9800','600':'#fb8c00','700':'#f57c00','800':'#ef6c00','900':'#e65100','A100':'#ffd180','A200':'#ffab40','A400':'#ff9100','A700':'#ff6d00' } },
'Orange foncé': { base: '#ff5722', shades: { '50':'#fbe9e7','100':'#ffccbc','200':'#ffab91','300':'#ff8a65','400':'#ff7043','500':'#ff5722','600':'#f4511e','700':'#e64a19','800':'#d84315','900':'#bf360c','A100':'#ff9e80','A200':'#ff6e40','A400':'#ff3d00','A700':'#dd2c00' } },
'Marron': { base: '#795548', shades: { '50':'#efebe9','100':'#d7ccc8','200':'#bcaaa4','300':'#a1887f','400':'#8d6e63','500':'#795548','600':'#6d4c41','700':'#5d4037','800':'#4e342e','900':'#3e2723' } },
'Gris': { base: '#9e9e9e', shades: { '50':'#fafafa','100':'#f5f5f5','200':'#eeeeee','300':'#e0e0e0','400':'#bdbdbd','500':'#9e9e9e','600':'#757575','700':'#616161','800':'#424242','900':'#212121' } },
'Gris bleu': { base: '#607d8b', shades: { '50':'#eceff1','100':'#cfd8dc','200':'#b0bec5','300':'#90a4ae','400':'#78909c','500':'#607d8b','600':'#546e7a','700':'#455a64','800':'#37474f','900':'#263238' } },
};
/* Retourne true si la couleur hex est claire (pour adapter la couleur du texte) */
function isLightColor(hex) {
const r = parseInt(hex.slice(1,3),16);
const g = parseInt(hex.slice(3,5),16);
const b = parseInt(hex.slice(5,7),16);
return (r * 299 + g * 587 + b * 114) / 1000 > 155;
}
/* Retrouve la famille et la teinte d'un hex donné */
function findInPalette(hex) {
const h = hex.toLowerCase();
for (const [family, { shades }] of Object.entries(MD_PALETTE)) {
for (const [shade, color] of Object.entries(shades)) {
if (color.toLowerCase() === h) return { family, shade };
}
}
return null;
}
/* Mélange une couleur hex avec du blanc (factor 0=original, 1=blanc) */
function lightenColor(hex, factor = 0.72) {
const r = parseInt(hex.slice(1,3),16);
const g = parseInt(hex.slice(3,5),16);
const b = parseInt(hex.slice(5,7),16);
const lr = Math.round(r + (255 - r) * factor);
const lg = Math.round(g + (255 - g) * factor);
const lb = Math.round(b + (255 - b) * factor);
return '#' + [lr,lg,lb].map(v => v.toString(16).padStart(2,'0')).join('');
}
/* ── Palettes suggérées ──────────────────────────────────────── */
const SUGGESTED_PALETTES = [
{ name: 'Classique', interets: '#2196f3', capital: '#4caf50', cashback: '#ffc107' },
{ name: 'Indigo & Teal', interets: '#3949ab', capital: '#009688', cashback: '#fb8c00' },
{ name: 'Nuit', interets: '#5c6bc0', capital: '#4dd0e1', cashback: '#ffca28' },
{ name: 'Nature', interets: '#43a047', capital: '#039be5', cashback: '#ff9800' },
{ name: 'Coucher de soleil', interets: '#ff5722', capital: '#1976d2', cashback: '#ffa000' },
{ name: 'Violet', interets: '#7e57c2', capital: '#26a69a', cashback: '#ffb300' },
{ name: 'Frais', interets: '#00acc1', capital: '#7cb342', cashback: '#ec407a' },
{ name: 'Contraste', interets: '#e53935', capital: '#1e88e5', cashback: '#43a047' },
{ name: 'Pastel', interets: '#29b6f6', capital: '#66bb6a', cashback: '#ffb74d' },
{ name: 'Moderne', interets: '#9c27b0', capital: '#00bcd4', cashback: '#ff9800' },
];
function PaletteSelector({ interets, capital, cashback, onSelect }) {
const isActive = (p) =>
p.interets === interets && p.capital === capital && p.cashback === cashback;
return (
<div style={{ marginBottom: 24 }}>
<p style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)', fontWeight: 600, color: 'var(--text)' }}>
Palettes suggérées
</p>
<div className="palette-grid">
{SUGGESTED_PALETTES.map((p) => (
<button
key={p.name}
type="button"
className={`palette-card${isActive(p) ? ' active' : ''}`}
onClick={() => onSelect(p)}
title={p.name}
>
<div className="palette-swatches">
<span className="palette-swatch" style={{ backgroundColor: p.interets }} />
<span className="palette-swatch" style={{ backgroundColor: p.capital }} />
<span className="palette-swatch" style={{ backgroundColor: p.cashback }} />
</div>
<span className="palette-name">{p.name}</span>
</button>
))}
</div>
</div>
);
}
function ChartColorPicker({ value, onChange }) {
const found = findInPalette(value);
const [selectedFamily, setSelectedFamily] = useState(found?.family || 'Bleu');
// Sync la famille quand value change depuis l'extérieur (sélection palette)
useEffect(() => {
const f = findInPalette(value);
if (f) setSelectedFamily(f.family);
}, [value]);
const familyData = MD_PALETTE[selectedFamily];
const shadeEntries = familyData ? Object.entries(familyData.shades) : [];
function handleFamilyChange(e) {
const fam = e.target.value;
setSelectedFamily(fam);
// Auto-select shade 500 (or first available) when changing family
const shades = MD_PALETTE[fam]?.shades || {};
const target = shades['500'] || Object.values(shades)[5] || Object.values(shades)[0];
if (target) onChange(target);
}
const textColor = isLightColor(value) ? '#212121' : '#ffffff';
return (
<div className="chart-color-picker">
<div className="color-family-select-row">
<span className="color-family-indicator" style={{ backgroundColor: familyData?.base || value }} />
<select value={selectedFamily} onChange={handleFamilyChange}>
{Object.keys(MD_PALETTE).map(name => (
<option key={name} value={name}>{name}</option>
))}
</select>
</div>
<div className="color-shade-grid">
{shadeEntries.map(([shade, hex]) => (
<button
key={shade}
type="button"
title={`${selectedFamily} ${shade}${hex}`}
className={`color-shade-btn${value.toLowerCase() === hex.toLowerCase() ? ' selected' : ''}`}
style={{ backgroundColor: hex }}
onClick={() => onChange(hex)}
>
<span className="color-shade-label" style={{ color: isLightColor(hex) ? '#000' : '#fff' }}>
{shade}
</span>
</button>
))}
</div>
<div className="color-preview-pair">
<div className="color-preview-bar" style={{ backgroundColor: value, color: textColor }}>
<span className="color-preview-label">Reçu</span>
<span className="color-preview-hex">{value.toUpperCase()}</span>
</div>
<div className="color-preview-bar" style={{ backgroundColor: lightenColor(value), color: isLightColor(lightenColor(value)) ? '#333' : '#fff' }}>
<span className="color-preview-label">Projeté</span>
<span className="color-preview-hex">{lightenColor(value).toUpperCase()}</span>
</div>
</div>
</div>
);
}
export default function AppearanceSection() {
const { mode, setMode } = useTheme();
const { fontScale, setFontScale, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback } = useUi();
const [libIcons, setLibIcons] = useState({});
useEffect(() => {
api.get('/icons').then(rows => {
const m = {};
rows.forEach(r => { m[r.name] = r.filename; });
setLibIcons(m);
}).catch(() => {});
}, []);
return (
<>
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Thème</h3>
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
Choisissez le thème visuel de l'application.
</p>
<div className="pref-options">
{THEMES.map((t) => (
<button key={t.mode} type="button"
className={`pref-option${mode === t.mode ? ' active' : ''}`}
onClick={() => setMode(t.mode)} aria-pressed={mode === t.mode}>
{t.preview}
<span style={{ fontWeight: 700, fontSize: 'var(--fs-sm)', marginTop: 4 }}>{t.label}</span>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>{t.desc}</span>
</button>
))}
</div>
</div>
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Taille du texte</h3>
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
Le niveau <strong>Grand</strong> est recommandé pour les personnes malvoyantes.
Les niveaux inférieurs permettent d'afficher plus de données à l'écran.
</p>
<div className="pref-options">
{FONTS.map((f) => (
<button key={f.scale} type="button"
className={`pref-option${fontScale === f.scale ? ' active' : ''}`}
onClick={() => setFontScale(f.scale)} aria-pressed={fontScale === f.scale}>
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4,
width: '100%', padding: '8px 0 4px',
borderBottom: '1px solid var(--border)', marginBottom: 4,
}}>
<span className="font-preview" style={{ fontSize: f.sizes.body }}>Aa — {f.label}</span>
<span style={{ fontSize: f.sizes.table, color: 'var(--text-muted)' }}>
Tableau {f.sizes.table}px · Corps {f.sizes.body}px
</span>
</div>
<span style={{ fontWeight: 700, fontSize: 'var(--fs-sm)', marginTop: 2 }}>{f.label}</span>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>{f.desc}</span>
</button>
))}
</div>
</div>
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Couleurs des graphiques</h3>
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
Choisissez une palette ci-dessous ou personnalisez chaque série individuellement.
</p>
<PaletteSelector
interets={chartInterets}
capital={chartCapital}
cashback={chartCashback}
onSelect={(p) => { setChartInterets(p.interets); setChartCapital(p.capital); setChartCashback(p.cashback); }}
/>
<div className="chart-color-row">
<div className="chart-color-item">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '1.1rem', fontWeight: 600 }}>
<AppIcon filename={libIcons.interets} size={33} />
Intérêts
</label>
<ChartColorPicker value={chartInterets} onChange={setChartInterets} />
</div>
<div className="chart-color-item">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '1.1rem', fontWeight: 600 }}>
<AppIcon filename={libIcons.capital} size={33} />
Capital
</label>
<ChartColorPicker value={chartCapital} onChange={setChartCapital} />
</div>
<div className="chart-color-item">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '1.1rem', fontWeight: 600 }}>
<AppIcon filename={libIcons.cashback} size={33} />
Cashback
</label>
<ChartColorPicker value={chartCashback} onChange={setChartCashback} />
</div>
</div>
</div>
</>
);
}
/* ── Icônes nav ──────────────────────────────────────────────── */
@@ -0,0 +1,197 @@
import { useState, useEffect } from 'react';
import { api } from '../../api.js';
export default function CategoriesInvSection() {
const [categoriesInv, setCategoriesInv] = useState([]);
const [err, setErr] = useState(null);
const [selectedCatInv, setSelectedCatInv] = useState(null);
const [editingCatInv, setEditingCatInv] = useState(null);
const [editingNomCatInv, setEditingNomCatInv] = useState('');
const [newCatInvNom, setNewCatInvNom] = useState('');
const [showNewCatInv, setShowNewCatInv] = useState(false);
const [catGlobalOpen, setCatGlobalOpen] = useState(false);
const [catPrivateOpen, setCatPrivateOpen] = useState(true);
useEffect(() => {
api.get('/categories-inv').then(data => {
setCategoriesInv(data.sort((a, b) => b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
}).catch(() => {});
}, []);
const saveCatInv = async (nom) => {
if (!nom.trim()) return;
try {
const row = await api.post('/categories-inv', { nom: nom.trim() });
setCategoriesInv(prev => [...prev, row].sort((a, b) =>
b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
setShowNewCatInv(false); setNewCatInvNom(''); setErr(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const renameCatInv = async (id, nom) => {
if (!nom.trim()) return;
try {
await api.put(`/categories-inv/${id}`, { nom: nom.trim() });
setCategoriesInv(prev => prev.map(c => c.id === id ? { ...c, nom: nom.trim() } : c));
setEditingCatInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const delCatInv = async (id) => {
try {
await api.del(`/categories-inv/${id}`);
setCategoriesInv(prev => prev.filter(c => c.id !== id));
if (selectedCatInv?.id === id) setSelectedCatInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const globalCats = categoriesInv.filter(c => c.is_global);
const privateCats = categoriesInv.filter(c => !c.is_global);
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<h3 style={{ margin: 0 }}>Mes catégories d'investissement</h3>
</div>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
{/* Accordéon — Catégories globalement définies */}
<div className="card" style={{ marginBottom: 10 }}>
<button type="button"
onClick={() => setCatGlobalOpen(o => !o)}
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--text)' }}>
<span style={{ fontWeight: 600, fontSize: 'var(--fs-base)' }}>
Catégories globalement définies
<span style={{ marginLeft: 8, fontWeight: 400, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
({globalCats.length})
</span>
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
style={{ transform: catGlobalOpen ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{catGlobalOpen && (
<div style={{ marginTop: 14 }}>
<table>
<thead>
<tr>
<th>Nom</th>
<th className="num">Plateformes</th>
<th className="num">Investissements</th>
</tr>
</thead>
<tbody>
{globalCats.length === 0 && (
<tr><td colSpan={3} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune catégorie globale</td></tr>
)}
{globalCats.map(c => (
<tr key={c.id}>
<td><span style={{ fontWeight: 600 }}>{c.nom}</span></td>
<td className="num">{c.nb_plateformes > 0 ? c.nb_plateformes : <span className="text-muted">—</span>}</td>
<td className="num">{c.nb_investissements > 0 ? c.nb_investissements : <span className="text-muted">—</span>}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Accordéon — Mes propres catégories */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<button type="button"
onClick={() => setCatPrivateOpen(o => !o)}
style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 8,
background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--text)', textAlign: 'left' }}>
<span style={{ fontWeight: 600, fontSize: 'var(--fs-base)' }}>
Mes propres catégories
<span style={{ marginLeft: 8, fontWeight: 400, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
({privateCats.length})
</span>
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
style={{ transform: catPrivateOpen ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{catPrivateOpen && (
<button className="primary" type="button" style={{ marginLeft: 12, flexShrink: 0 }}
onClick={() => { setCatPrivateOpen(true); setShowNewCatInv(true); setNewCatInvNom(''); setErr(null); }}>
+ Ajouter
</button>
)}
</div>
{catPrivateOpen && (
<div style={{ marginTop: 14 }}>
{showNewCatInv && (
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<input autoFocus style={{ flex: 1 }} placeholder="Nom de la catégorie"
value={newCatInvNom} onChange={e => setNewCatInvNom(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') saveCatInv(newCatInvNom); if (e.key === 'Escape') setShowNewCatInv(false); }} />
<button className="primary" onClick={() => saveCatInv(newCatInvNom)}>Créer</button>
<button onClick={() => setShowNewCatInv(false)}>Annuler</button>
</div>
)}
<table>
<thead>
<tr>
<th>Nom</th>
<th className="num">Plateformes</th>
<th className="num">Investissements</th>
<th style={{ width: 80 }}></th>
</tr>
</thead>
<tbody>
{privateCats.length === 0 && (
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune catégorie personnelle</td></tr>
)}
{privateCats.map(c => (
<tr key={c.id}>
<td>
{editingCatInv === c.id ? (
<div style={{ display: 'flex', gap: 6 }}>
<input autoFocus style={{ flex: 1 }} value={editingNomCatInv}
onChange={e => setEditingNomCatInv(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') renameCatInv(c.id, editingNomCatInv); if (e.key === 'Escape') setEditingCatInv(null); }} />
<button className="primary" onClick={() => renameCatInv(c.id, editingNomCatInv)}>OK</button>
<button onClick={() => setEditingCatInv(null)}>✕</button>
</div>
) : (
<span style={{ fontWeight: 600 }}>{c.nom}</span>
)}
</td>
<td className="num">{c.nb_plateformes > 0 ? c.nb_plateformes : <span className="text-muted">—</span>}</td>
<td className="num">{c.nb_investissements > 0 ? c.nb_investissements : <span className="text-muted">—</span>}</td>
<td>
{editingCatInv !== c.id && (
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
<button style={{ fontSize: 12, padding: '2px 8px' }}
onClick={() => { setEditingCatInv(c.id); setEditingNomCatInv(c.nom); setErr(null); }}>
Renommer
</button>
<button style={{ fontSize: 12, padding: '2px 8px', color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => setConfirmDelete({
title: 'Supprimer la catégorie',
message: `Supprimer la catégorie "${c.nom}" ? Elle sera retirée de toutes les plateformes et investissements.`,
confirmLabel: 'Supprimer',
onConfirm: () => { delCatInv(c.id); setConfirmDelete(null); }
})}>
Supprimer
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,250 @@
import { useState, useEffect, useCallback } from 'react';
import { api } from '../../api.js';
import { memberLabel } from '../../utils/format.js';
import ConfirmModal from '../../components/ConfirmModal.jsx';
import Modal from '../../components/Modal.jsx';
const EXONERATION_LABELS = {
aucune: 'Aucune',
pfnl_5ans: 'PFnl 5 ans',
};
const TYPE_COMPTE_LABELS = {
compte_courant: 'Compte courant',
pea_pme: 'PEA-PME',
};
const EMPTY_COMPTE = { nom: '', type: 'compte_courant', investisseur_id: null, banque: '', exoneration_fiscale: 'aucune' };
function compteInvestisseur(c) {
if (!c.investisseur_id) return null;
return { id: c.investisseur_id, nom: c.investisseur_nom, prenom: c.investisseur_prenom, type: c.investisseur_type, type_fiscal: c.investisseur_type_fiscal };
}
function CompteFormFields({ state, setter, investisseurs }) {
return (
<>
<div>
<label>Nom *</label>
<input required value={state.nom}
onChange={e => setter({ ...state, nom: e.target.value })}
placeholder="ex. Compte courant BNP" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label>Type *</label>
<select value={state.type} onChange={e => setter({ ...state, type: e.target.value })}>
<option value="compte_courant">Compte courant</option>
<option value="pea_pme">PEA-PME</option>
</select>
</div>
<div>
<label>Banque</label>
<input value={state.banque}
onChange={e => setter({ ...state, banque: e.target.value })}
placeholder="ex. BNP Paribas" />
</div>
</div>
<div>
<label>Détenteur</label>
<select value={state.investisseur_id ?? ''}
onChange={e => setter({ ...state, investisseur_id: e.target.value ? Number(e.target.value) : null })}>
<option value=""> Non renseigné </option>
{investisseurs.map(inv => (
<option key={inv.id} value={inv.id}>{memberLabel(inv)}</option>
))}
</select>
</div>
<div>
<label>Exonération fiscale</label>
<select value={state.exoneration_fiscale ?? 'aucune'}
onChange={e => setter({ ...state, exoneration_fiscale: e.target.value })}>
<option value="aucune">Aucune exonération fiscale</option>
<option value="pfnl_5ans">Exonération Impôts sur le revenu (PFNL) si détention 5 ans</option>
</select>
</div>
</>
);
}
export default function ComptesSection() {
const [comptes, setComptes] = useState([]);
const [investisseurs, setInvestisseurs] = useState([]);
const reload = useCallback(async () => {
const [cpts, invs] = await Promise.all([
api.get('/comptes'),
api.get('/investisseurs'),
]);
setComptes(cpts);
setInvestisseurs(invs);
}, []);
useEffect(() => { reload(); }, [reload]);
const [showNew, setShowNew] = useState(false);
const [newCompte, setNewCompte] = useState(EMPTY_COMPTE);
const [editCompte, setEditCompte] = useState(null);
const [err, setErr] = useState(null);
const [confirmDel, setConfirmDel] = useState(null);
const openNew = () => { setNewCompte(EMPTY_COMPTE); setErr(null); setShowNew(true); };
const addCompte = async (e) => {
e.preventDefault(); setErr(null);
try {
await api.post('/comptes', {
nom: newCompte.nom,
type: newCompte.type,
banque: newCompte.banque || null,
investisseur_id: newCompte.investisseur_id ? Number(newCompte.investisseur_id) : null,
exoneration_fiscale: newCompte.exoneration_fiscale ?? 'aucune',
});
setShowNew(false); setNewCompte(EMPTY_COMPTE); reload();
} catch (ex) { setErr(ex.message); }
};
const saveEdit = async (e) => {
e.preventDefault(); setErr(null);
try {
await api.put(`/comptes/${editCompte.id}`, {
nom: editCompte.nom,
type: editCompte.type,
banque: editCompte.banque || null,
investisseur_id: editCompte.investisseur_id ? Number(editCompte.investisseur_id) : null,
exoneration_fiscale: editCompte.exoneration_fiscale ?? 'aucune',
});
setEditCompte(null); reload();
} catch (ex) { setErr(ex.message); }
};
const openEdit = (c) => {
setErr(null);
setEditCompte({ id: c.id, nom: c.nom, type: c.type, banque: c.banque || '', investisseur_id: c.investisseur_id ?? null, exoneration_fiscale: c.exoneration_fiscale ?? 'aucune' });
};
const del = (c) => {
setConfirmDel({
message: `Supprimer le compte "${c.nom}" ?`,
onConfirm: async () => {
try { await api.del(`/comptes/${c.id}`); reload(); }
catch (ex) { setErr(ex.message); }
finally { setConfirmDel(null); }
},
});
};
return (
<>
<div className="card">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 16 }}>
<div>
<h3 style={{ margin: 0 }}>Mes comptes courants</h3>
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: '4px 0 0' }}>
Comptes bancaires et enveloppes financières associés à vos investisseurs.
</p>
</div>
<button className="primary" style={{ whiteSpace: 'nowrap' }} onClick={openNew}>
+ Nouveau compte
</button>
</div>
{err && <div className="error" style={{ marginBottom: 10 }}>{err}</div>}
<table>
<thead>
<tr>
<th>Nom</th>
<th style={{ width: '14%' }}>Type</th>
<th style={{ width: '22%' }}>Détenteur</th>
<th style={{ width: '16%' }}>Banque</th>
<th style={{ width: '10%' }}>Exonération</th>
<th style={{ width: 80 }} />
</tr>
</thead>
<tbody>
{comptes.length === 0 && (
<tr><td colSpan={6} className="text-muted" style={{ textAlign: 'center', fontStyle: 'italic' }}>
Aucun compte défini.
</td></tr>
)}
{comptes.map(c => {
const inv = compteInvestisseur(c);
return (
<tr key={c.id}>
<td style={{ fontWeight: 500 }}>{c.nom}</td>
<td><span className="badge">{TYPE_COMPTE_LABELS[c.type] ?? c.type}</span></td>
<td>{inv ? memberLabel(inv) : <span className="text-muted"></span>}</td>
<td>{c.banque || <span className="text-muted"></span>}</td>
<td>
{c.exoneration_fiscale && c.exoneration_fiscale !== 'aucune' ? (
<span
title={EXONERATION_LABELS[c.exoneration_fiscale] ?? c.exoneration_fiscale}
style={{ cursor: 'help', color: 'var(--success)', fontWeight: 600, fontSize: 'var(--fs-sm)' }}
>Oui</span>
) : (
<span className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>Non</span>
)}
</td>
<td>
<div style={{ display: 'flex', gap: 4 }}>
<button style={{ padding: '3px 10px', fontSize: 11 }} onClick={() => openEdit(c)}>Modifier</button>
<button className="danger" style={{ padding: '3px 10px', fontSize: 11 }} onClick={() => del(c)}></button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* ── Modal création ── */}
<Modal
open={showNew}
title="Nouveau compte"
onClose={() => { setShowNew(false); setNewCompte(EMPTY_COMPTE); setErr(null); }}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%', gap: 8 }}>
<button type="button" onClick={() => { setShowNew(false); setNewCompte(EMPTY_COMPTE); setErr(null); }}>Annuler</button>
<button className="primary" form="form-new-compte" type="submit">Créer</button>
</div>
}
>
<form id="form-new-compte" onSubmit={addCompte} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{err && <div className="error">{err}</div>}
<CompteFormFields state={newCompte} setter={setNewCompte} investisseurs={investisseurs} />
</form>
</Modal>
{/* ── Modal édition ── */}
<Modal
open={!!editCompte}
title="Modifier le compte"
onClose={() => { setEditCompte(null); setErr(null); }}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<button className="danger" type="button" onClick={() => { setEditCompte(null); del(comptes.find(c => c.id === editCompte?.id) ?? editCompte); }}>
Supprimer
</button>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" onClick={() => { setEditCompte(null); setErr(null); }}>Annuler</button>
<button className="primary" form="form-edit-compte" type="submit">Enregistrer</button>
</div>
</div>
}
>
<form id="form-edit-compte" onSubmit={saveEdit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{err && <div className="error">{err}</div>}
<CompteFormFields state={editCompte} setter={setEditCompte} investisseurs={investisseurs} />
</form>
</Modal>
{/* ── ConfirmModal suppression ── */}
<ConfirmModal
open={!!confirmDel}
message={confirmDel?.message}
onConfirm={confirmDel?.onConfirm}
onCancel={() => setConfirmDel(null)}
/>
</>
);
}
@@ -0,0 +1,304 @@
import { useState } from 'react';
import { api } from '../../api.js';
function IconBroom() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 21l9-9"/><path d="M12.22 6.22L17 1.5l5.5 5.5-4.72 4.78"/><path d="M5 17c.5-2 2-3.5 4-4.5l3.5 3.5c-1 2-2.5 3.5-4.5 4"/></svg>;
}
export default function DataCleanupSection() {
const [showModal, setShowModal] = useState(false);
const [loading, setLoading] = useState(false);
const [showReprocessModal, setShowReprocessModal] = useState(false);
const [loadingReprocess, setLoadingReprocess] = useState(false);
const [showDiffereModal, setShowDiffereModal] = useState(false);
const [loadingDiffere, setLoadingDiffere] = useState(false);
const [showBackfillModal, setShowBackfillModal] = useState(false);
const [loadingBackfill, setLoadingBackfill] = useState(false);
const [successMsg, setSuccessMsg] = useState(null);
const [errorMsg, setErrorMsg] = useState(null);
const handleReprocess = async () => {
setLoadingReprocess(true);
setErrorMsg(null);
setSuccessMsg(null);
try {
const { updated } = await api.post('/remboursements/reprocess', {});
setSuccessMsg(`${updated} remboursement${updated > 1 ? 's' : ''} recalculé${updated > 1 ? 's' : ''} avec succès.`);
setShowReprocessModal(false);
} catch (err) {
setErrorMsg(err.message || 'Une erreur est survenue.');
setShowReprocessModal(false);
} finally {
setLoadingReprocess(false);
}
};
const handleReassign = async () => {
setLoading(true);
setErrorMsg(null);
setSuccessMsg(null);
try {
await api.post('/investisseurs/reassign-to-principal', {});
setSuccessMsg('Toutes les données ont été réaffectées au compte principal.');
setShowModal(false);
} catch (err) {
setErrorMsg(err.message || 'Une erreur est survenue.');
setShowModal(false);
} finally {
setLoading(false);
}
};
const handleFixDiffereDates = async () => {
setLoadingDiffere(true);
setErrorMsg(null);
setSuccessMsg(null);
try {
const { updated, detail } = await api.post('/investissements/fix-differe-dates', {});
if (updated === 0) {
setSuccessMsg('Aucune date incohérente détectée sur les prêts différés.');
} else {
setSuccessMsg(
`${updated} prêt${updated > 1 ? 's' : ''} différé${updated > 1 ? 's' : ''} corrigé${updated > 1 ? 's' : ''} : ` +
detail.map(d => d.nom_projet).join(', ') + '.'
);
}
setShowDiffereModal(false);
} catch (err) {
setErrorMsg(err.message || 'Une erreur est survenue.');
setShowDiffereModal(false);
} finally {
setLoadingDiffere(false);
}
};
const handleBackfillComptes = async () => {
setLoadingBackfill(true);
setErrorMsg(null);
setSuccessMsg(null);
try {
const { updated, total } = await api.post('/remboursements/backfill-comptes', {});
if (updated === 0) {
setSuccessMsg(`Aucun remboursement à corriger (${total} vérifié${total > 1 ? 's' : ''}).`);
} else {
setSuccessMsg(`${updated} remboursement${updated > 1 ? 's' : ''} mis à jour sur ${total} vérifié${total > 1 ? 's' : ''}.`);
}
setShowBackfillModal(false);
} catch (err) {
setErrorMsg(err.message || 'Une erreur est survenue.');
setShowBackfillModal(false);
} finally {
setLoadingBackfill(false);
}
};
return (
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Nettoyage de données</h3>
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
Opérations de maintenance sur les données du compte.
</p>
{errorMsg && <div className="error" style={{ marginBottom: 12 }}>{errorMsg}</div>}
{successMsg && <div className="success-msg" style={{ marginBottom: 12 }}>{successMsg}</div>}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderRadius: 8,
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))',
marginBottom: 10 }}>
<div>
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
Recalculer les champs fiscaux des remboursements
</div>
<div className="text-muted" style={{ fontSize: 12 }}>
Recalcule prélèvements sociaux, impôt sur le revenu, intérêts nets et net reçu de tous
vos remboursements selon la fiscalité de chaque plateforme et les taux PFU de l'année.
Met également à jour les retraits automatiques associés.
</div>
</div>
<button
style={{ marginLeft: 16, whiteSpace: 'nowrap', flexShrink: 0 }}
onClick={() => setShowReprocessModal(true)}
disabled={loadingReprocess}
>
Recalculer
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderRadius: 8,
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))',
marginBottom: 10 }}>
<div>
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
Corriger les dates des prêts différés
</div>
<div className="text-muted" style={{ fontSize: 12 }}>
Recalcule la date de 1ère échéance et la date cible à partir de la date de souscription
et de la durée prévue. Seuls les prêts dont les dates s'écartent de plus de 2 ans
de la valeur calculée sont corrigés.
</div>
</div>
<button
style={{ marginLeft: 16, whiteSpace: 'nowrap', flexShrink: 0 }}
onClick={() => setShowDiffereModal(true)}
disabled={loadingDiffere}
>
Corriger
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderRadius: 8,
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))',
marginBottom: 10 }}>
<div>
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
Lier les remboursements aux comptes courants
</div>
<div className="text-muted" style={{ fontSize: 12 }}>
Pour chaque remboursement en mode "Compte courant" sans compte lié, associe automatiquement
le compte de l'investissement ou le premier compte courant du détenteur.
Les remboursements déjà liés ou redirigés vers le porte-monnaie ne sont pas touchés.
</div>
</div>
<button
style={{ marginLeft: 16, whiteSpace: 'nowrap', flexShrink: 0 }}
onClick={() => setShowBackfillModal(true)}
disabled={loadingBackfill}
>
Lier
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderRadius: 8,
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))' }}>
<div>
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
Réaffecter l'ensemble des données au compte principal
</div>
<div className="text-muted" style={{ fontSize: 12 }}>
Investissements, dépôts/retraits tous les enregistrements seront attribués au titulaire principal.
</div>
</div>
<button
className="danger"
style={{ marginLeft: 16, whiteSpace: 'nowrap', flexShrink: 0 }}
onClick={() => setShowModal(true)}
disabled={loading}
>
Réaffecter
</button>
</div>
{showDiffereModal && (
<div className="modal-overlay" onClick={() => setShowDiffereModal(false)}>
<div className="modal" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 16 }}>
<h3 style={{ margin: 0 }}>Corriger les dates des prêts différés</h3>
</div>
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }}>
Pour chaque prêt de type <strong>différé</strong>, la date cible et la date de 1ère échéance
seront recalculées comme suit :
</p>
<p style={{ margin: '0 0 12px', lineHeight: 1.6, fontFamily: 'monospace', fontSize: 13,
background: 'var(--surface-2, var(--bg))', padding: '8px 12px', borderRadius: 6,
border: '1px solid var(--border)' }}>
date souscription + durée (mois)
</p>
<p style={{ margin: '0 0 20px', lineHeight: 1.6 }} className="text-muted">
La correction ne s'applique que si l'écart entre la date existante et la date calculée
dépasse <strong>2 ans</strong>. Les simulations de remboursement associées ne sont pas
recalculées automatiquement.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => setShowDiffereModal(false)} disabled={loadingDiffere}>Annuler</button>
<button className="primary" onClick={() => handleFixDiffereDates()} disabled={loadingDiffere}>
{loadingDiffere ? 'Correction en cours…' : 'Confirmer la correction'}
</button>
</div>
</div>
</div>
)}
{showBackfillModal && (
<div className="modal-overlay" onClick={() => setShowBackfillModal(false)}>
<div className="modal" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 16 }}>
<h3 style={{ margin: 0 }}>Lier les remboursements aux comptes courants</h3>
</div>
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }}>
Pour chaque remboursement en mode <strong>"Compte courant de l'investisseur"</strong> sans compte lié, cette opération va :
</p>
<ul style={{ margin: '0 0 12px', paddingLeft: 20, lineHeight: 1.8, fontSize: 'var(--fs-sm)' }}>
<li>Utiliser le compte défini sur l'investissement lié, si disponible</li>
<li>Sinon, prendre le premier compte courant du détenteur</li>
</ul>
<p style={{ margin: '0 0 20px', lineHeight: 1.6 }} className="text-muted">
Les remboursements déjà liés à un compte ou redirigés vers le porte-monnaie ne sont pas modifiés.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => setShowBackfillModal(false)} disabled={loadingBackfill}>Annuler</button>
<button className="primary" onClick={handleBackfillComptes} disabled={loadingBackfill}>
{loadingBackfill ? 'Traitement en cours' : 'Confirmer'}
</button>
</div>
</div>
</div>
)}
{showReprocessModal && (
<div className="modal-overlay" onClick={() => setShowReprocessModal(false)}>
<div className="modal" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 16 }}>
<h3 style={{ margin: 0 }}>Recalculer les remboursements</h3>
</div>
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }}>
Cette opération va recalculer pour <strong>tous vos remboursements</strong> les champs suivants :
</p>
<ul style={{ margin: '0 0 12px', paddingLeft: 20, lineHeight: 1.8, fontSize: 'var(--fs-sm)' }}>
<li>Prélèvements sociaux et impôt sur le revenu (taux PFU de l'année)</li>
<li>Taxe locale (pour les plateformes avec fiscalité locale)</li>
<li>Intérêts nets et montant net reçu</li>
<li>Montant des retraits automatiques associés</li>
</ul>
<p style={{ margin: '0 0 20px', lineHeight: 1.6 }} className="text-muted">
Les valeurs saisies manuellement seront écrasées. Cette opération ne peut pas être annulée.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => setShowReprocessModal(false)} disabled={loadingReprocess}>Annuler</button>
<button className="primary" onClick={handleReprocess} disabled={loadingReprocess}>
{loadingReprocess ? 'Recalcul en cours…' : 'Confirmer le recalcul'}
</button>
</div>
</div>
</div>
)}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal" style={{ maxWidth: 440 }} onClick={e => e.stopPropagation()}>
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 16 }}>
<h3 style={{ margin: 0, color: 'var(--danger, #ef4444)' }}> Action irréversible</h3>
</div>
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }}>
Cette action va réaffecter <strong>l'ensemble de vos investissements et mouvements financiers</strong> au compte principal.
</p>
<p style={{ margin: '0 0 20px', lineHeight: 1.6 }} className="text-muted">
Les données actuellement rattachées à d'autres membres ou entreprises seront transférées au titulaire principal. Cette opération ne peut pas être annulée.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => setShowModal(false)} disabled={loading}>Annuler</button>
<button className="danger" onClick={handleReassign} disabled={loading}>
{loading ? 'Réaffectation…' : 'Confirmer la réaffectation'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,499 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../../api.js';
import { fmtDate } from '../../utils/format.js';
import { useInvestisseur } from '../../context/InvestisseurContext.jsx';
import ResultBanner from '../../components/ResultBanner.jsx';
function dlBlob(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code ?? '—';
const FISCALITE_LABELS = {
flat_tax: 'Flat Tax',
sans_fiscalite_locale: 'Sans fiscalité locale',
avec_fiscalite_locale: 'Avec fiscalité locale',
};
const METHODE_REMB_LABELS = {
portefeuille: 'Porte-monnaie de la plateforme',
compte_courant: "Compte courant de l'investisseur",
choix_investisseur: "Au choix de l'investisseur (sur la plateforme)",
};
/** Reconstruit un objet investisseur minimal depuis les colonnes dénormalisées de la plateforme */
function IconCSV() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="10" y1="9" x2="14" y2="9"/></svg>;
}
function IconXLS() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l2 2 4-4"/></svg>;
}
function IconJSON() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M8 13h1.5a1.5 1.5 0 0 1 0 3H8v-3z"/><path d="M14 13h2v1.5a1.5 1.5 0 0 1-3 0V13z"/></svg>;
}
function IconImport() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 14 12 9 17 14"/><line x1="12" y1="9" x2="12" y2="21"/></svg>;
}
/* ── ExportDropdown ──────────────────────────────────────────── */
function ExportDropdown({ disabled, onCSV, onXLS, onJSON, title = 'Exporter' }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const handler = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const choose = (fn) => { setOpen(false); fn(); };
return (
<div ref={ref} style={{ position: 'relative' }}>
<button type="button" className="icon-btn" disabled={disabled}
onClick={() => setOpen(o => !o)} title={title}
aria-haspopup="menu" aria-expanded={open}>
<IconExport />
</button>
{open && (
<div className="export-dropdown" role="menu">
<button role="menuitem" onClick={() => choose(onCSV)}>
<IconCSV /><span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
</button>
<button role="menuitem" onClick={() => choose(onXLS)}>
<IconXLS /><span><strong>Format Excel</strong><small>Fichier .xls natif Microsoft</small></span>
</button>
{onJSON && (
<button role="menuitem" onClick={() => choose(onJSON)}>
<IconJSON /><span><strong>Format JSON</strong><small>Données structurées</small></span>
</button>
)}
</div>
)}
</div>
);
}
/* ── Helpers PFU ─────────────────────────────────────────────── */
function IconUpload() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>;
}
/* ── Imports — constantes ────────────────────────────────────── */
const MODULES = {
depots_retraits: {
label: 'Dépôts / Retraits',
required: ['date_operation', 'type', 'montant'],
optional: ['plateforme_id', 'libelle', 'reference'],
needsInvestisseur: true,
},
investissements: {
label: 'Investissements',
required: ['nom_projet', 'date_souscription', 'montant_investi'],
optional: ['plateforme_id', 'emetteur', 'date_premiere_echeance', 'date_cible', 'taux_interet', 'duree_mois', 'type_remb', 'freq_interets', 'statut', 'reference'],
needsInvestisseur: true,
},
remboursements: {
label: 'Remboursements',
required: ['investissement_id', 'date_remb'],
optional: ['capital', 'interets_bruts', 'prelev_sociaux', 'prelev_forfaitaire', 'net_recu', 'statut'],
needsInvestisseur: true,
},
plateformes: {
label: 'Plateformes',
required: ['nom'],
optional: ['url', 'notes'],
needsInvestisseur: false,
note: 'Les plateformes dont le nom existe déjà seront ignorées (pas d\'écrasement).',
},
taux_pfu: {
label: 'Flat Tax — Taux PFU',
required: ['annee', 'pfu_total', 'impot_revenu', 'prelev_sociaux'],
optional: [],
needsInvestisseur: false,
global: true,
note: 'Table de référence globale. Si une année existe déjà, ses taux seront mis à jour (upsert).',
},
};
const MODULE_LABEL = {
depots_retraits: 'Dépôts / Retraits',
investissements: 'Investissements',
remboursements: 'Remboursements',
plateformes: 'Plateformes',
taux_pfu: 'Flat Tax — Taux PFU',
};
/* ── Imports — composant dossier ─────────────────────────────── */
function DossierImport({
activeId, navigate,
dossierFile, setDossierFile,
dossierPreview, setDossierPreview,
dossierResult, setDossierResult,
dossierBusy, setDossierBusy,
dossierErr, setDossierErr,
dossierInputRef, reloadHistory,
}) {
const missingInv = !activeId;
const onFileChange = (e) => {
const f = e.target.files[0];
setDossierFile(f || null);
setDossierPreview(null); setDossierResult(null); setDossierErr(null);
if (!f) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const parsed = JSON.parse(ev.target.result);
if (parsed.type !== 'dossier_investissement') {
setDossierErr('Ce fichier n\'est pas un dossier investissement valide (type incorrect).');
return;
}
setDossierPreview(parsed);
} catch { setDossierErr('Fichier JSON invalide — vérifiez la syntaxe.'); }
};
reader.readAsText(f);
};
const onImport = async () => {
if (!dossierPreview) return;
setDossierBusy(true); setDossierErr(null); setDossierResult(null);
try {
const r = await api.post('/imports/dossier', { dossier: dossierPreview });
setDossierResult(r);
setDossierFile(null); setDossierPreview(null);
if (dossierInputRef.current) dossierInputRef.current.value = '';
reloadHistory();
} catch (e) { setDossierErr(e.message); }
finally { setDossierBusy(false); }
};
const dp = dossierPreview;
const inv = dp?.investissement;
return (
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Import Dossier investissement</h3>
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', marginBottom: 12 }}>
Restaure ou migre un dossier complet (investissement + remboursements + historique) depuis un fichier
<code style={{ margin: '0 4px' }}>.json</code> exporté par cette application.
Si le dossier existe déjà, il sera mis à jour ; sinon il sera créé.
</p>
{missingInv && (
<div className="error" style={{ marginBottom: 10 }}>
Sélectionnez un investisseur actif avant d'importer un dossier.
</div>
)}
<div className="row" style={{ gap: 10, alignItems: 'flex-end' }}>
<div style={{ flex: 1 }}>
<label>Fichier dossier <code>.json</code></label>
<input ref={dossierInputRef} type="file" accept=".json"
disabled={missingInv} onChange={onFileChange} />
</div>
</div>
{dossierErr && <div className="error" style={{ marginTop: 10 }}>{dossierErr}</div>}
{dp && inv && (
<div style={{ marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 14 }}>
<h4 style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)' }}>Aperçu du dossier</h4>
<table style={{ marginBottom: 0 }}>
<tbody>
<tr><td style={{ width: 200 }}>Projet</td><td><strong>{inv.nom_projet}</strong></td></tr>
<tr><td>Plateforme</td><td>{dp.plateforme?.nom}</td></tr>
<tr><td>Date souscription</td><td>{fmtDate(inv.date_souscription)}</td></tr>
<tr><td>Montant investi</td><td>{inv.montant_investi} €</td></tr>
<tr><td>Statut</td><td>{inv.statut}</td></tr>
<tr><td>Remboursements</td><td>{dp.remboursements?.length ?? 0} enregistrement(s)</td></tr>
<tr><td>Réinvestissements</td><td>{dp.reinvestissements?.length ?? 0} enregistrement(s)</td></tr>
<tr><td>Projections</td><td>{dp.projections?.length ?? 0} échéance(s)</td></tr>
<tr><td>Historique</td><td>{dp.historique?.length ?? 0} entrée(s)</td></tr>
<tr><td>Exporté le</td><td className="text-muted" style={{ fontSize: 11 }}>{dp.exported_at}</td></tr>
</tbody>
</table>
<div style={{ marginTop: 12, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button onClick={() => { setDossierFile(null); setDossierPreview(null); if (dossierInputRef.current) dossierInputRef.current.value = ''; }}>
Annuler
</button>
<button className="primary" onClick={onImport} disabled={dossierBusy || missingInv}>
{dossierBusy ? '' : 'Importer ce dossier'}
</button>
</div>
</div>
)}
{dossierResult && (
<div className="success-msg" style={{ marginTop: 12 }}>
{dossierResult.action === 'created' ? ' Dossier créé avec succès.' : ' Dossier mis à jour avec succès.'}
{' '}
<button
style={{ marginLeft: 8, fontSize: 'var(--fs-xs)', padding: '2px 8px' }}
onClick={() => navigate(`/investissements/${dossierResult.investissementId}`)}
>
Ouvrir le dossier →
</button>
</div>
)}
</div>
);
}
/* ── Imports — section principale ────────────────────────────── */
export default function ImportsSection() {
const { activeId } = useInvestisseur();
const navigate = useNavigate();
const [module, setModule] = useState('depots_retraits');
const [file, setFile] = useState(null);
const [preview, setPreview] = useState(null);
const [mapping, setMapping] = useState({});
const [defaults, setDefaults] = useState({});
const [plats, setPlats] = useState([]);
const [investissements, setInvestissements] = useState([]);
const [history, setHistory] = useState([]);
const [result, setResult] = useState(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState(null);
const [dossierFile, setDossierFile] = useState(null);
const [dossierPreview, setDossierPreview] = useState(null);
const [dossierResult, setDossierResult] = useState(null);
const [dossierBusy, setDossierBusy] = useState(false);
const [dossierErr, setDossierErr] = useState(null);
const dossierInputRef = useRef(null);
useEffect(() => {
api.get('/imports/history').then(setHistory).catch(() => {});
api.get('/plateformes').then(setPlats).catch(() => {});
}, []);
useEffect(() => {
if (!activeId) return;
api.get('/investissements').then(setInvestissements).catch(() => {});
}, [activeId]);
const def = MODULES[module];
const allTargets = def ? [...def.required, ...def.optional] : [];
const missingInv = def?.needsInvestisseur && !activeId;
const onPreview = async () => {
if (!file) return;
setBusy(true); setErr(null); setResult(null);
try {
const fd = new FormData();
fd.append('file', file);
const r = await api.upload('/imports/preview', fd);
setPreview(r);
const auto = {};
for (const t of allTargets) {
const col = r.headers.find(h => h.toLowerCase().replace(/\W/g, '_') === t);
if (col) auto[t] = col;
}
setMapping(auto);
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
};
const apply = async () => {
setBusy(true); setErr(null);
try {
const r = await api.post('/imports/apply', {
tempId: preview.tempId, module, mapping, defaults,
originalFilename: file?.name ?? preview.filename,
});
setResult({
ok: true,
msg: `✔ Import terminé : ${r.inserted} / ${r.total} lignes insérées${r.skipped > 0 ? `, ${r.skipped} ignorées` : ''}.${r.errors?.length > 0 ? ` (${r.errors.length} avertissement(s))` : ''}`,
});
setPreview(null); setFile(null); setMapping({}); setDefaults({});
api.get('/imports/history').then(setHistory).catch(() => {});
if (module === 'plateformes') api.get('/plateformes').then(setPlats).catch(() => {});
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
};
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
return (
<>
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>1. Fichier source</h3>
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
Importez des données depuis un fichier Excel, CSV ou JSON.
</p>
<div className="row">
<div>
<label>Module cible</label>
<select value={module} onChange={e => {
setModule(e.target.value);
setPreview(null); setMapping({}); setResult(null); setErr(null);
}}>
{Object.entries(MODULES).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
</div>
<div style={{ flex: 2 }}>
<label>Fichier .xlsx, .csv ou .json</label>
<input type="file" accept=".xlsx,.xls,.csv,.json" onChange={e => {
setFile(e.target.files[0]);
setPreview(null); setResult(null); setErr(null);
}} />
</div>
<div>
<button className="primary" onClick={onPreview} disabled={!file || busy || missingInv}>
{busy ? '' : 'Analyser'}
</button>
</div>
</div>
{def?.note && (
<div className="import-module-note">
{def.global && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ flexShrink: 0, marginTop: 1, color: 'var(--warning)' }}>
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
)}
{def.note}
</div>
)}
{missingInv && (
<div className="error" style={{ marginTop: 10 }}>
Sélectionnez un investisseur actif avant d'importer ce module.
</div>
)}
{err && <div className="error" style={{ marginTop: 12 }}>{err}</div>}
<ResultBanner result={result} onDismiss={() => setResult(null)} style={{ marginTop: 12 }} />
</div>
{preview && (
<>
<div className="card">
<h3 style={{ marginTop: 0 }}>2. Mappage des colonnes</h3>
<p className="text-muted" style={{ fontSize: 12 }}>
Fichier : <strong>{preview.filename}</strong> feuille <em>{preview.sheetName}</em> {preview.allRowCount} lignes.
{' '}Champs marqués <span style={{ color: 'var(--danger)' }}>*</span> obligatoires.
{' '}Si la colonne n'existe pas, fournissez une valeur par défaut.
</p>
<table>
<thead>
<tr><th>Champ cible</th><th>Colonne Excel</th><th>Valeur par défaut</th></tr>
</thead>
<tbody>
{allTargets.map(t => {
const isReq = def.required.includes(t);
return (
<tr key={t}>
<td>
<code style={{ fontSize: 11 }}>{t}</code>
{isReq && <span style={{ color: 'var(--danger)' }}> *</span>}
</td>
<td>
<select value={mapping[t] || ''} onChange={e => setMapping({ ...mapping, [t]: e.target.value })}>
<option value="">— ignorer —</option>
{preview.headers.map(h => <option key={h} value={h}>{h}</option>)}
</select>
</td>
<td>
{t === 'plateforme_id' ? (
<select value={defaults[t] || ''} onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}>
<option value="">—</option>
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
</select>
) : t === 'investissement_id' ? (
<select value={defaults[t] || ''} onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}>
<option value="">—</option>
{investissements.map(i => <option key={i.id} value={i.id}>{i.nom_projet}</option>)}
</select>
) : (
<input value={defaults[t] || ''} onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}
placeholder={t === 'statut' ? 'ex. en_cours' : t === 'type' ? 'ex. depot' : ''} />
)}
</td>
</tr>
);
})}
</tbody>
</table>
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => { setPreview(null); setMapping({}); }}>Annuler</button>
<button className="primary" onClick={apply} disabled={busy || missingInv}>
{busy ? '' : `Importer ${preview.allRowCount} lignes`}
</button>
</div>
</div>
<div className="card">
<h3 style={{ marginTop: 0 }}>Aperçu (10 premières lignes)</h3>
<div style={{ overflowX: 'auto' }}>
<table>
<thead><tr>{preview.headers.map(h => <th key={h}>{h}</th>)}</tr></thead>
<tbody>
{preview.sampleRows.map((r, i) => (
<tr key={i}>{preview.headers.map(h => <td key={h}>{String(r[h] ?? '')}</td>)}</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
<DossierImport
activeId={activeId}
navigate={navigate}
dossierFile={dossierFile} setDossierFile={setDossierFile}
dossierPreview={dossierPreview} setDossierPreview={setDossierPreview}
dossierResult={dossierResult} setDossierResult={setDossierResult}
dossierBusy={dossierBusy} setDossierBusy={setDossierBusy}
dossierErr={dossierErr} setDossierErr={setDossierErr}
dossierInputRef={dossierInputRef}
reloadHistory={() => api.get('/imports/history').then(setHistory).catch(() => {})}
/>
<div className="card">
<h3 style={{ marginTop: 0 }}>Historique des imports</h3>
<table>
<thead>
<tr>
<th>Date</th><th>Module</th><th>Fichier</th>
<th className="num">Total</th><th className="num">OK</th><th className="num">KO</th>
</tr>
</thead>
<tbody>
{history.length === 0 && (
<tr><td colSpan={6} className="text-muted" style={{ textAlign: 'center' }}>Aucun import</td></tr>
)}
{history.map(h => (
<tr key={h.id}>
<td>{fmtDate(h.created_at)}</td>
<td>{MODULE_LABEL[h.module] ?? h.module}</td>
<td className="text-muted" style={{ fontSize: 11 }}>{h.filename}</td>
<td className="num">{h.rows_total}</td>
<td className="num" style={{ color: 'var(--success)' }}>{h.rows_inserted}</td>
<td className="num" style={{ color: h.rows_skipped > 0 ? 'var(--warning)' : undefined }}>{h.rows_skipped}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}
@@ -0,0 +1,94 @@
import { useState } from 'react';
import { useUi } from '../../context/UiContext.jsx';
export default function MaFiscaliteSection() {
const { pfoAssujetti, setPfoAssujetti } = useUi();
const [showPfoDetail, setShowPfoDetail] = useState(false);
return (
<div>
<h2 style={{ margin: '0 0 6px', fontSize: 18 }}>Ma fiscalité</h2>
<p style={{ margin: '0 0 24px', color: 'var(--text-muted)', fontSize: 13 }}>
Paramètres fiscaux personnels applicables à vos revenus de placements.
</p>
<div style={{ fontWeight: 700, fontSize: 'var(--fs-md)', marginBottom: 10 }}>
Fiscalité des plateformes étrangères
</div>
<div className="card" style={{ marginBottom: 16, borderLeft: `4px solid ${pfoAssujetti ? 'var(--primary)' : 'var(--border)'}`, transition: 'border-color .2s' }}>
{/* Ligne titre + chevron + toggle */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<button
type="button"
onClick={() => setShowPfoDetail(v => !v)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex', alignItems: 'center', color: 'var(--text)' }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ transform: showPfoDetail ? 'rotate(180deg)' : 'none', transition: 'transform .2s', marginRight: 6, flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
<span style={{ fontWeight: 700, fontSize: 'var(--fs-base)' }}>
CERFA 2778-SD Prélèvement Forfaitaire Obligatoire (PFO) pour des revenus de source étrangère
</span>
</button>
<div style={{ marginLeft: 'auto', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 'var(--fs-xs)', fontWeight: 600, color: pfoAssujetti ? 'var(--primary)' : 'var(--text-muted)' }}>
{pfoAssujetti ? 'Activé' : 'Désactivé'}
</span>
<button
type="button"
onClick={() => setPfoAssujetti(!pfoAssujetti)}
style={{
width: 48, height: 26, borderRadius: 13, border: 'none',
background: pfoAssujetti ? 'var(--primary)' : 'var(--border)',
cursor: 'pointer', position: 'relative', transition: 'background .2s',
}}
>
<span style={{
position: 'absolute', top: 3,
left: pfoAssujetti ? 25 : 3,
width: 20, height: 20, borderRadius: '50%',
background: '#fff', transition: 'left .2s',
boxShadow: '0 1px 4px rgba(0,0,0,0.2)',
}} />
</button>
</div>
</div>
{showPfoDetail && (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)', lineHeight: 1.6 }}>
Les intérêts perçus via des plateformes <strong>étrangères</strong> sont soumis à un prélèvement
forfaitaire obligatoire non libératoire de <strong>12,8 %</strong> (+ prélèvements sociaux),
à déclarer mensuellement via le formulaire <strong>2778-SD</strong> dans les 15 premiers jours
du mois suivant l'encaissement.
</div>
<div style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)', lineHeight: 1.6, marginTop: 8 }}>
Ce prélèvement s'applique aux personnes physiques fiscalement domiciliées en France dont
le <strong>revenu fiscal de référence</strong> de l'avant-dernière année est égal ou supérieur à :
</div>
<div style={{ display: 'flex', gap: 12, marginTop: 8, flexWrap: 'wrap' }}>
<div style={{
padding: '6px 14px', borderRadius: 20,
background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.2)',
fontSize: 'var(--fs-sm)', fontWeight: 600,
}}>25 000 € — célibataire, divorcé ou veuf</div>
<div style={{
padding: '6px 14px', borderRadius: 20,
background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.2)',
fontSize: 'var(--fs-sm)', fontWeight: 600,
}}>50 000 € — couple marié ou pacsé</div>
</div>
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 8 }}>
En dessous de ces seuils, vous êtes dispensé du PFO — mais les prélèvements sociaux restent dus.
Le PFO versé via la 2778-SD constitue un simple acompte d'impôt sur le revenu,
imputable sur l'impôt définitif calculé lors de la déclaration 2042.
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,736 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../../api.js';
import InvSelect from '../../components/InvSelect.jsx';
import Modal from '../../components/Modal.jsx';
import ConfirmModal from '../../components/ConfirmModal.jsx';
import CountrySelect, { COUNTRIES, FlagIcon } from '../../components/CountrySelect.jsx';
import ResultBanner from '../../components/ResultBanner.jsx';
import { useInvestisseur } from '../../context/InvestisseurContext.jsx';
import { memberLabel, fmtDate } from '../../utils/format.js';
function dlBlob(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code ?? '—';
const FISCALITE_LABELS = {
flat_tax: 'Flat Tax',
sans_fiscalite_locale: 'Sans fiscalité locale',
avec_fiscalite_locale: 'Avec fiscalité locale',
};
const METHODE_REMB_LABELS = {
portefeuille: 'Porte-monnaie de la plateforme',
compte_courant: "Compte courant de l'investisseur",
choix_investisseur: "Au choix de l'investisseur (sur la plateforme)",
};
/** Reconstruit un objet investisseur minimal depuis les colonnes dénormalisées de la plateforme */
function platInvestisseur(p) {
if (!p.investisseur_id) return null;
return { id: p.investisseur_id, nom: p.investisseur_nom, prenom: p.investisseur_prenom, type: p.investisseur_type, type_fiscal: p.investisseur_type_fiscal };
}
function fmtFiscalite(p) {
if (!p.fiscalite) return '—';
const base = FISCALITE_LABELS[p.fiscalite] ?? p.fiscalite;
if (p.fiscalite === 'avec_fiscalite_locale' && p.taux_fiscalite_locale != null) {
return `${base} (${p.taux_fiscalite_locale} %)`;
}
return base;
}
const EMPTY_PLAT = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', methode_remboursement: 'portefeuille', investisseur_id: null, date_ouverture: '', logo_filename: null, type_pret_defaut: '', freq_interets_defaut: '', referentiel_id: null };
const LOGO_BASE = (import.meta.env.VITE_API_URL || '/api').replace(/\/api$/, '') + '/api/logos/';
const logoUrl = (filename) => filename ? LOGO_BASE + filename : null;
function applyDomiciliationChange(state, newDomicil) {
const next = { ...state, domiciliation: newDomicil };
if (newDomicil === 'FR') {
next.fiscalite = 'flat_tax';
next.taux_fiscalite_locale = '';
} else if (state.fiscalite === 'flat_tax') {
next.fiscalite = 'sans_fiscalite_locale';
next.taux_fiscalite_locale = '';
}
return next;
}
function applyFiscaliteChange(state, newFiscalite) {
const next = { ...state, fiscalite: newFiscalite };
if (newFiscalite !== 'avec_fiscalite_locale') next.taux_fiscalite_locale = '';
return next;
}
function platsToCSV(plats) {
const BOM = '';
const sep = ';';
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
const headers = ['ID', 'Nom', 'URL', 'Domiciliation', 'Fiscalité', 'Taux fiscal local (%)', 'Créé le'];
const rows = plats.map(p => [
p.id, p.nom, p.url || '',
countryLabel(p.domiciliation),
FISCALITE_LABELS[p.fiscalite] ?? p.fiscalite ?? '',
p.taux_fiscalite_locale ?? '',
p.created_at ? new Date(p.created_at).toLocaleDateString('fr-FR') : '',
]);
return BOM + [headers, ...rows].map(r => r.map(q).join(sep)).join('\r\n');
}
function platsToXLS(plats) {
const esc = v => String(v ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
const cell = (v, t = 'String') => `<Cell><Data ss:Type="${t}">${esc(v)}</Data></Cell>`;
const mkRow = cells => ` <Row>${cells.join('')}</Row>`;
const header = mkRow(['ID','Nom','URL','Domiciliation','Fiscalité','Taux fiscal local (%)','Créé le'].map(h => cell(h)));
const dataRows = plats.map(p => mkRow([
cell(p.id, 'Number'), cell(p.nom), cell(p.url || ''),
cell(countryLabel(p.domiciliation)),
cell(FISCALITE_LABELS[p.fiscalite] ?? p.fiscalite ?? ''),
cell(p.taux_fiscalite_locale ?? ''),
cell(p.created_at ? new Date(p.created_at).toLocaleDateString('fr-FR') : ''),
])).join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
<Styles><Style ss:ID="h"><Font ss:Bold="1"/></Style></Styles>
<Worksheet ss:Name="Plateformes">
<Table>
${header}
${dataRows}
</Table>
</Worksheet>
</Workbook>`;
}
/* ── Icônes utilitaires ──────────────────────────────────────── */
function IconExport() {
return <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>;
}
function IconCSV() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="10" y1="9" x2="14" y2="9"/></svg>;
}
function IconXLS() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l2 2 4-4"/></svg>;
}
function IconJSON() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M8 13h1.5a1.5 1.5 0 0 1 0 3H8v-3z"/><path d="M14 13h2v1.5a1.5 1.5 0 0 1-3 0V13z"/></svg>;
}
function IconImport() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 14 12 9 17 14"/><line x1="12" y1="9" x2="12" y2="21"/></svg>;
}
/* ── ExportDropdown ──────────────────────────────────────────── */
function ExportDropdown({ disabled, onCSV, onXLS, onJSON, title = 'Exporter' }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const handler = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const choose = (fn) => { setOpen(false); fn(); };
return (
<div ref={ref} style={{ position: 'relative' }}>
<button type="button" className="icon-btn" disabled={disabled}
onClick={() => setOpen(o => !o)} title={title}
aria-haspopup="menu" aria-expanded={open}>
<IconExport />
</button>
{open && (
<div className="export-dropdown" role="menu">
<button role="menuitem" onClick={() => choose(onCSV)}>
<IconCSV /><span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
</button>
<button role="menuitem" onClick={() => choose(onXLS)}>
<IconXLS /><span><strong>Format Excel</strong><small>Fichier .xls natif Microsoft</small></span>
</button>
{onJSON && (
<button role="menuitem" onClick={() => choose(onJSON)}>
<IconJSON /><span><strong>Format JSON</strong><small>Données structurées</small></span>
</button>
)}
</div>
)}
</div>
);
}
/* ── Helpers PFU ─────────────────────────────────────────────── */
function PlatDetailPanel({ plat, onEdit }) {
const navigate = useNavigate();
const [platCatsInv, setPlatCatsInv] = useState([]);
const [platSectsInv, setPlatSectsInv] = useState([]);
useEffect(() => {
if (!plat) { setPlatCatsInv([]); setPlatSectsInv([]); return; }
Promise.all([
api.get(`/plateformes/${plat.id}/categories-inv`).catch(() => []),
api.get(`/plateformes/${plat.id}/secteurs-inv`).catch(() => []),
]).then(([cats, sects]) => { setPlatCatsInv(cats); setPlatSectsInv(sects); });
}, [plat?.id]);
if (!plat) return (
<div className="dr-detail dr-detail-empty">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.25, marginBottom: 8 }}>
<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
<span>Sélectionnez une plateforme</span>
</div>
);
const inv = platInvestisseur(plat);
const fields = [
{ label: 'Plateforme', value: plat.nom },
plat.url && {
label: 'Site web',
value: <a href={plat.url} target="_blank" rel="noreferrer"
style={{ wordBreak: 'break-all', color: 'var(--primary)' }}>{plat.url}</a>,
},
{ label: 'Détenteur', value: inv ? memberLabel(inv) : '—' },
plat.date_ouverture && { label: "Date d'ouverture", value: fmtDate(plat.date_ouverture) },
{ label: 'Domiciliation', value: plat.domiciliation ? <span style={{ display:'inline-flex', alignItems:'center', gap:5 }}><FlagIcon code={plat.domiciliation} size={16} />{countryLabel(plat.domiciliation)}</span> : '—' },
{ label: 'Fiscalité', value: fmtFiscalite(plat) },
plat.domiciliation === 'FR' && {
label: 'Déclaration 2561',
value: (plat.type_produit_fiscal ?? '2TT') === '2TR'
? 'Case 2TR — Produits de placement à revenu fixe'
: 'Case 2TT — Produits des minibons et prêts participatifs',
},
{ label: 'Méthode de remboursement', value: METHODE_REMB_LABELS[plat.methode_remboursement] ?? '—' },
platCatsInv.length > 0 && {
label: "Catégories d'investissement",
value: <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>{platCatsInv.map(c => <span key={c.id} className="chip-cat">{c.nom}</span>)}</div>,
},
platSectsInv.length > 0 && {
label: "Secteurs d'investissement",
value: <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>{platSectsInv.map(s => <span key={s.id} className="chip-sect">{s.nom}</span>)}</div>,
},
plat.type_pret_defaut && { label: 'Type de prêt (défaut)', value: { in_fine: 'In fine', amortissable: 'Amortissable', differe: 'Différé' }[plat.type_pret_defaut] ?? plat.type_pret_defaut },
plat.freq_interets_defaut && { label: 'Périodicité (défaut)', value: { mensuel: 'Mensuelle', trimestriel: 'Trimestrielle', in_fine: 'In fine' }[plat.freq_interets_defaut] ?? plat.freq_interets_defaut },
{
label: 'Investissements',
value: plat.nb_investissements != null
? `${plat.nb_investissements} investissement${plat.nb_investissements !== 1 ? 's' : ''}`
: '—',
},
plat.notes && { label: 'Notes', value: plat.notes },
].filter(Boolean);
const logo = logoUrl(plat.logo_filename);
return (
<div className="dr-detail">
{logo && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '12px 0 4px' }}>
<img src={logo} alt={`Logo ${plat.nom}`} className="logo-plateforme"
style={{ maxHeight: 56, maxWidth: 160, objectFit: 'contain' }}
onError={e => { e.currentTarget.style.display = 'none'; }}
/>
</div>
)}
<div className="dr-detail-title">Détail de la plateforme</div>
<div className="dr-detail-fields">
{fields.map(f => (
<div className="dr-detail-field" key={f.label}>
<span className="dr-detail-label">{f.label}</span>
<span className="dr-detail-value">{f.value}</span>
</div>
))}
</div>
<div className="dr-detail-footer">
{plat.referentiel_id && (
<button
className="ghost"
onClick={() => navigate(`/referentiel/${plat.referentiel_id}`)}
style={{ marginBottom: 8, width: '100%', fontSize: 13 }}
>
Voir le profil de la plateforme
</button>
)}
<button className="dr-detail-edit-btn" onClick={() => onEdit(plat)}>
Modifier la plateforme
</button>
</div>
</div>
);
}
/* ── Panneau de détail PFU ───────────────────────────────────── */
export default function PlateformesSection() {
// ── State ──────────────────────────────────────────────────────
const [plats, setPlats] = useState([]);
const [investisseurs, setInvestisseurs] = useState([]);
const [referentiel, setReferentiel] = useState([]);
const [newPlat, setNewPlat] = useState(EMPTY_PLAT);
const [newPlatLogoFile, setNewPlatLogoFile] = useState(null);
const [newPlatLogoPreview, setNewPlatLogoPreview] = useState(null);
const [editPlat, setEditPlat] = useState(null);
const [editPlatLogoFile, setEditPlatLogoFile] = useState(null);
const [editPlatLogoPreview, setEditPlatLogoPreview] = useState(null);
const [selectedPlat, setSelectedPlat] = useState(null);
const [showNewPlat, setShowNewPlat] = useState(false);
const [platOpenMenu, setPlatOpenMenu] = useState(null); // { plat, x, y }
const [platExporting, setPlatExporting] = useState(false);
const [platImportResult, setPlatImportResult] = useState(null);
// Catégories d'investissement (globales + privées)
const [categoriesInv, setCategoriesInv] = useState([]);
const [selectedCatInv, setSelectedCatInv] = useState(null);
const [editingCatInv, setEditingCatInv] = useState(null); // id en cours d'édition
const [editingNomCatInv, setEditingNomCatInv] = useState('');
const [newCatInvNom, setNewCatInvNom] = useState('');
const [showNewCatInv, setShowNewCatInv] = useState(false);
// Secteurs d'investissement (globaux + privés)
const [secteursInv, setSecteursInv] = useState([]);
const [selectedSectInv, setSelectedSectInv] = useState(null);
const [editingSectInv, setEditingSectInv] = useState(null);
const [editingNomSectInv, setEditingNomSectInv] = useState('');
const [newSectInvNom, setNewSectInvNom] = useState('');
const [showNewSectInv, setShowNewSectInv] = useState(false);
// PFU
const [showPfoDetail, setShowPfoDetail] = useState(false);
const [err, setErr] = useState(null);
const [msg, setMsg] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const platImportRef = useRef(null);
const load = async () => {
const [p, invs, ref, catInv, sectInv] = await Promise.all([
api.get('/plateformes'),
api.get('/investisseurs'),
api.get('/plateformes/referentiel-list'),
api.get('/categories-inv'),
api.get('/secteurs-inv'),
]);
setPlats(p);
setInvestisseurs(invs);
setReferentiel(ref);
setCategoriesInv(catInv);
setSecteursInv(sectInv);
setSelectedPlat(prev => prev ? (p.find(x => x.id === prev.id) ?? null) : null);
};
useEffect(() => { load(); }, []); // eslint-disable-line
useEffect(() => {
if (plats.length === 0) return;
setSelectedPlat(prev => prev ? prev : plats[0]);
}, [plats]); // eslint-disable-line
const handleLogoFile = (file, setFile, setPreview) => {
if (!file) { setFile(null); setPreview(null); return; }
setFile(file);
const reader = new FileReader();
reader.onload = (ev) => setPreview(ev.target.result);
reader.readAsDataURL(file);
};
/* ── Catégories d'investissement (privées) ──────────────────────── */
const saveCatInv = async (nom) => {
if (!nom.trim()) return;
try {
const row = await api.post('/categories-inv', { nom: nom.trim() });
setCategoriesInv(prev => [...prev, row].sort((a, b) =>
b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
setShowNewCatInv(false); setNewCatInvNom(''); setErr(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const renameCatInv = async (id, nom) => {
if (!nom.trim()) return;
try {
await api.put(`/categories-inv/${id}`, { nom: nom.trim() });
setCategoriesInv(prev => prev.map(c => c.id === id ? { ...c, nom: nom.trim() } : c));
setEditingCatInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const delCatInv = async (id) => {
try {
await api.del(`/categories-inv/${id}`);
setCategoriesInv(prev => prev.filter(c => c.id !== id));
if (selectedCatInv?.id === id) setSelectedCatInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
/* ── Secteurs d'investissement (privés) ──────────────────────── */
const saveSectInv = async (nom) => {
if (!nom.trim()) return;
try {
const row = await api.post('/secteurs-inv', { nom: nom.trim() });
setSecteursInv(prev => [...prev, row].sort((a, b) =>
b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
setShowNewSectInv(false); setNewSectInvNom(''); setErr(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const renameSectInv = async (id, nom) => {
if (!nom.trim()) return;
try {
await api.put(`/secteurs-inv/${id}`, { nom: nom.trim() });
setSecteursInv(prev => prev.map(s => s.id === id ? { ...s, nom: nom.trim() } : s));
setEditingSectInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const delSectInv = async (id) => {
try {
await api.del(`/secteurs-inv/${id}`);
setSecteursInv(prev => prev.filter(s => s.id !== id));
if (selectedSectInv?.id === id) setSelectedSectInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
/** Upload le logo vers le serveur après que la plateforme a été créée/sauvée */
const uploadLogo = async (platId, file) => {
if (!file) return null;
const fd = new FormData();
fd.append('logo', file);
return api.upload(`/plateformes/${platId}/logo`, fd);
};
/* ── Plateformes ─────────────────────────────────────────────── */
const addPlat = async (e) => {
e.preventDefault(); setErr(null); setMsg(null);
try {
const created = await api.post('/plateformes', {
...newPlat,
taux_fiscalite_locale: newPlat.fiscalite === 'avec_fiscalite_locale' && newPlat.taux_fiscalite_locale !== ''
? Number(newPlat.taux_fiscalite_locale) : null,
methode_remboursement: newPlat.methode_remboursement || 'portefeuille',
investisseur_id: newPlat.investisseur_id ? Number(newPlat.investisseur_id) : null,
date_ouverture: newPlat.date_ouverture || null,
type_pret_defaut: newPlat.type_pret_defaut || null,
freq_interets_defaut: newPlat.freq_interets_defaut || null,
referentiel_id: newPlat.referentiel_id ? Number(newPlat.referentiel_id) : null,
});
if (newPlatLogoFile) await uploadLogo(created.id, newPlatLogoFile);
setNewPlat(EMPTY_PLAT);
setNewPlatLogoFile(null);
setNewPlatLogoPreview(null);
setShowNewPlat(false);
await load();
} catch (e) { setErr(e.message); }
};
const delPlat = (id) => {
setConfirmDelete({
message: 'Supprimer cette plateforme ?',
onConfirm: async () => {
try {
await api.del(`/plateformes/${id}`);
setSelectedPlat(null);
await load();
} catch (e) { setErr(e.message); }
finally { setConfirmDelete(null); }
},
});
};
// ── Export/Import ZIP plateformes ─────────────────────────────────────
const handlePlatExportAll = async () => {
try {
setPlatExporting(true);
const blob = await api.blob('/plateformes/export');
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `plateformes-${new Date().toISOString().slice(0, 10)}.zip`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) { setPlatImportResult({ ok: false, msg: e.message }); }
finally { setPlatExporting(false); }
};
const handlePlatExportOne = async (plat) => {
try {
const blob = await api.blob(`/plateformes/${plat.id}/export`);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${plat.nom.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${new Date().toISOString().slice(0, 10)}.zip`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) { setPlatImportResult({ ok: false, msg: e.message }); }
};
const handlePlatImportZip = async (file) => {
try {
const fd = new FormData();
fd.append('file', file);
const r = await api.upload('/plateformes/import-zip', fd);
setPlatImportResult({ ok: true, msg: `Import terminé : ${r.created} créée(s), ${r.updated} mise(s) à jour sur ${r.total} entrée(s).` });
load();
} catch (e) { setPlatImportResult({ ok: false, msg: e.message }); }
};
const openEditPlat = async (p) => {
setEditPlatLogoFile(null);
setEditPlatLogoPreview(null);
// Charger les associations catégories/secteurs d'investissement de la plateforme
const [platCatsInv, platSectsInv] = await Promise.all([
api.get(`/plateformes/${p.id}/categories-inv`).catch(() => []),
api.get(`/plateformes/${p.id}/secteurs-inv`).catch(() => []),
]);
setEditPlat({
id: p.id, nom: p.nom, url: p.url || '',
categories_inv_ids: platCatsInv.map(c => c.id),
secteurs_inv_ids: platSectsInv.map(s => s.id),
inherited_cat_ids: platCatsInv.filter(c => c.is_inherited).map(c => c.id),
inherited_sect_ids: platSectsInv.filter(s => s.is_inherited).map(s => s.id),
domiciliation: p.domiciliation || 'france',
fiscalite: p.fiscalite || 'flat_tax',
taux_fiscalite_locale: p.taux_fiscalite_locale ?? '',
type_produit_fiscal: p.type_produit_fiscal || '2TT',
methode_remboursement: p.methode_remboursement || 'portefeuille',
investisseur_id: p.investisseur_id ?? null,
date_ouverture: p.date_ouverture || '',
logo_filename: p.logo_filename || null,
type_pret_defaut: p.type_pret_defaut || '',
freq_interets_defaut: p.freq_interets_defaut || '',
referentiel_id: p.referentiel_id ?? null,
referentiel_nom: p.referentiel_nom ?? null,
overridden_fields: p.overridden_fields ?? [],
});
};
const saveEditPlat = async (e) => {
e.preventDefault(); setErr(null); setMsg(null);
try {
await api.put(`/plateformes/${editPlat.id}`, {
nom: editPlat.nom, url: editPlat.url || '',
domiciliation: editPlat.domiciliation,
fiscalite: editPlat.fiscalite,
taux_fiscalite_locale: editPlat.fiscalite === 'avec_fiscalite_locale' && editPlat.taux_fiscalite_locale !== ''
? Number(editPlat.taux_fiscalite_locale) : null,
type_produit_fiscal: editPlat.type_produit_fiscal || '2TT',
methode_remboursement: editPlat.methode_remboursement || 'portefeuille',
investisseur_id: editPlat.investisseur_id ? Number(editPlat.investisseur_id) : null,
date_ouverture: editPlat.date_ouverture || null,
type_pret_defaut: editPlat.type_pret_defaut || null,
freq_interets_defaut: editPlat.freq_interets_defaut || null,
});
if (editPlatLogoFile) await uploadLogo(editPlat.id, editPlatLogoFile);
// Sauvegarder les associations catégories/secteurs d'investissement
await Promise.all([
api.put(`/plateformes/${editPlat.id}/categories-inv`, { ids: editPlat.categories_inv_ids || [] }),
api.put(`/plateformes/${editPlat.id}/secteurs-inv`, { ids: editPlat.secteurs_inv_ids || [] }),
]);
setMsg('Plateforme mise à jour.');
setTimeout(() => setMsg(null), 3000);
setEditPlatLogoFile(null);
setEditPlatLogoPreview(null);
setEditPlat(null);
await load();
} catch (e) { setErr(e.message); }
};
const delLogoPlat = async (id) => {
try {
await api.del(`/plateformes/${id}/logo`);
setEditPlat(ep => ep ? { ...ep, logo_filename: null } : ep);
await load();
} catch (e) { setErr(e.message); }
};
/* ── PFU CRUD ────────────────────────────────────────────────── */
return (
<>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
{msg && <div className="success-msg" style={{ marginBottom: 12 }}>{msg}</div>}
<div className="dr-mouvements-layout">
{/* Colonne gauche — liste */}
<div className="dr-mouvements-list">
<div className="card" style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div>
<h3 style={{ margin: 0 }}>Mes plateformes</h3>
<p className="text-muted" style={{ margin: '4px 0 0', fontSize: 'var(--fs-sm)' }}>
Plateformes de crowdlending que vous utilisez.
</p>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<ExportDropdown
disabled={plats.length === 0}
onCSV={() => dlBlob(platsToCSV(plats), 'plateformes.csv', 'text/csv;charset=utf-8')}
onXLS={() => dlBlob(platsToXLS(plats), 'plateformes.xls', 'application/vnd.ms-excel')}
/>
<button onClick={handlePlatExportAll} disabled={platExporting || plats.length === 0}
title="Exporter toutes les plateformes en ZIP"
style={{ padding: '7px 12px', borderRadius: 6, border: '1px solid var(--border)', fontSize: 13, fontWeight: 600, cursor: 'pointer', background: 'var(--surface-2)', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 5 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
{platExporting ? '…' : 'Export ZIP'}
</button>
<button onClick={() => platImportRef.current?.click()}
title="Importer un fichier ZIP de plateformes"
style={{ padding: '7px 12px', borderRadius: 6, border: '1px solid var(--border)', fontSize: 13, fontWeight: 600, cursor: 'pointer', background: 'var(--surface-2)', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 5 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Import ZIP
</button>
<input ref={platImportRef} type="file" accept=".zip" style={{ display: 'none' }}
onChange={e => { const f = e.target.files[0]; e.target.value = ''; if (f) handlePlatImportZip(f); }} />
<button className="primary" type="button"
onClick={() => { setNewPlat(EMPTY_PLAT); setErr(null); setShowNewPlat(true); }}>
+ Ajouter
</button>
</div>
</div>
{platImportResult && <ResultBanner result={platImportResult} onDismiss={() => setPlatImportResult(null)} style={{ marginBottom: 12 }} />}
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
<th style={{ padding: '10px 8px', width: 40 }}></th>
<th style={{ padding: '10px 16px', textAlign: 'left' }}>Nom</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Domiciliation</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Catégories</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Secteurs</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Détenteur</th>
<th style={{ padding: '10px 16px', textAlign: 'center', width: 60 }}>Invest.</th>
<th style={{ padding: '10px 8px', width: 40 }}></th>
</tr>
</thead>
<tbody>
{plats.length === 0 && (
<tr><td colSpan={7} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune plateforme</td></tr>
)}
{plats.map((p, i) => {
const imgSrc = p.icone_filename ? logoUrl(p.icone_filename) : p.logo_filename ? logoUrl(p.logo_filename) : null;
const inv = platInvestisseur(p);
const cats = p.categories_inv || [];
const sects = p.secteurs_inv || [];
return (
<tr key={p.id}
onClick={() => setSelectedPlat(selectedPlat?.id === p.id ? null : p)}
style={{
borderBottom: i < plats.length - 1 ? '1px solid var(--border)' : 'none',
cursor: 'pointer',
background: selectedPlat?.id === p.id ? 'var(--surface-2)' : 'none',
}}
onMouseEnter={e => { if (selectedPlat?.id !== p.id) e.currentTarget.style.background = 'var(--surface-2)'; }}
onMouseLeave={e => { if (selectedPlat?.id !== p.id) e.currentTarget.style.background = 'none'; }}>
<td style={{ padding: '8px 8px 8px 16px', width: 40 }}>
{imgSrc
? <img src={imgSrc} alt="" style={{ width: 32, height: 32, objectFit: 'contain', borderRadius: 4, display: 'block' }} />
: <div style={{ width: 32, height: 32, borderRadius: 4, background: 'var(--surface-2)', border: '1px dashed var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-muted)', fontSize: 16 }}>×</div>
}
</td>
<td style={{ padding: '10px 16px' }}>
<div style={{ fontWeight: 600 }}>{p.nom}</div>
</td>
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}>
{p.domiciliation
? <span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<FlagIcon code={p.domiciliation} size={15} />{countryLabel(p.domiciliation)}
</span>
: '—'}
</td>
<td style={{ padding: '10px 8px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{cats.slice(0, 2).map(c => <span key={c.id} className="chip-cat" style={{ fontSize: 11 }}>{c.nom}</span>)}
{cats.length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{cats.length - 2}</span>}
{cats.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}></span>}
</div>
</td>
<td style={{ padding: '10px 8px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{sects.slice(0, 2).map(s => <span key={s.id} className="chip-sect" style={{ fontSize: 11 }}>{s.nom}</span>)}
{sects.length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{sects.length - 2}</span>}
{sects.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}></span>}
</div>
</td>
<td style={{ padding: '10px 8px', fontSize: 12, color: 'var(--text-muted)' }}>
{inv ? memberLabel(inv) : '—'}
</td>
<td style={{ padding: '10px 16px', textAlign: 'center', fontWeight: 600,
color: (p.nb_investissements ?? 0) > 0 ? 'var(--text)' : 'var(--text-muted)' }}>
{p.nb_investissements ?? 0}
</td>
<td style={{ padding: '10px 8px', textAlign: 'right' }}>
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 4, fontSize: 18, color: 'var(--text-muted)', lineHeight: 1 }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={e => { e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); setPlatOpenMenu({ plat: p, x: rect.right, y: rect.bottom }); }}
></button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
{/* Colonne droite — détail */}
<div className="dr-mouvements-detail">
<PlatDetailPanel
plat={selectedPlat}
onEdit={openEditPlat}
/>
</div>
</div>
{/* Menu ⋮ plateformes */}
{platOpenMenu && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setPlatOpenMenu(null)} />
<div style={{ position: 'fixed', left: platOpenMenu.x, top: platOpenMenu.y,
transform: 'translateX(-100%) translateY(4px)', zIndex: 300,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)', padding: '4px 0', minWidth: 170 }}>
{[
{ icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>, label: 'Modifier',
onClick: () => { const p = platOpenMenu.plat; setPlatOpenMenu(null); openEditPlat(p); } },
{ icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>, label: 'Exporter',
onClick: () => { const p = platOpenMenu.plat; setPlatOpenMenu(null); handlePlatExportOne(p); } },
{ icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>, label: 'Supprimer',
onClick: () => { const p = platOpenMenu.plat; setPlatOpenMenu(null); delPlat(p.id); }, color: 'var(--danger)' },
].map(({ icon, label, onClick, color }) => (
<button key={label}
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px',
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 'var(--fs-sm)', color: color || 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={onClick}>
<span style={{ opacity: 0.7, flexShrink: 0, display: 'flex' }}>{icon}</span>
{label}
</button>
))}
</div>
</>
)}
<ConfirmModal
open={!!confirmDelete}
title="Supprimer la plateforme"
message={confirmDelete?.message}
onConfirm={confirmDelete?.onConfirm}
onCancel={() => setConfirmDelete(null)}
/>
</>
);
}
@@ -0,0 +1,196 @@
import { useState, useEffect } from 'react';
import { api } from '../../api.js';
export default function SecteursInvSection() {
const [secteursInv, setSecteursInv] = useState([]);
const [err, setErr] = useState(null);
const [selectedSectInv, setSelectedSectInv] = useState(null);
const [editingSectInv, setEditingSectInv] = useState(null);
const [editingNomSectInv, setEditingNomSectInv] = useState('');
const [newSectInvNom, setNewSectInvNom] = useState('');
const [showNewSectInv, setShowNewSectInv] = useState(false);
const [sectGlobalOpen, setSectGlobalOpen] = useState(false);
const [sectPrivateOpen, setSectPrivateOpen] = useState(true);
useEffect(() => {
api.get('/secteurs-inv').then(data => {
setSecteursInv(data.sort((a, b) => b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
}).catch(() => {});
}, []);
const saveSectInv = async (nom) => {
if (!nom.trim()) return;
try {
const row = await api.post('/secteurs-inv', { nom: nom.trim() });
setSecteursInv(prev => [...prev, row].sort((a, b) =>
b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
setShowNewSectInv(false); setNewSectInvNom(''); setErr(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const renameSectInv = async (id, nom) => {
if (!nom.trim()) return;
try {
await api.put(`/secteurs-inv/${id}`, { nom: nom.trim() });
setSecteursInv(prev => prev.map(s => s.id === id ? { ...s, nom: nom.trim() } : s));
setEditingSectInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const delSectInv = async (id) => {
try {
await api.del(`/secteurs-inv/${id}`);
setSecteursInv(prev => prev.filter(s => s.id !== id));
if (selectedSectInv?.id === id) setSelectedSectInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const globalSects = secteursInv.filter(s => s.is_global);
const privateSects = secteursInv.filter(s => !s.is_global);
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<h3 style={{ margin: 0 }}>Mes secteurs d'investissement</h3>
</div>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
{/* Accordéon — Secteurs globalement définis */}
<div className="card" style={{ marginBottom: 10 }}>
<button type="button"
onClick={() => setSectGlobalOpen(o => !o)}
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--text)' }}>
<span style={{ fontWeight: 600, fontSize: 'var(--fs-base)' }}>
Secteurs globalement définis
<span style={{ marginLeft: 8, fontWeight: 400, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
({globalSects.length})
</span>
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
style={{ transform: sectGlobalOpen ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{sectGlobalOpen && (
<div style={{ marginTop: 14 }}>
<table>
<thead>
<tr>
<th>Nom</th>
<th className="num">Plateformes</th>
<th className="num">Investissements</th>
</tr>
</thead>
<tbody>
{globalSects.length === 0 && (
<tr><td colSpan={3} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucun secteur global</td></tr>
)}
{globalSects.map(s => (
<tr key={s.id}>
<td><span style={{ fontWeight: 600 }}>{s.nom}</span></td>
<td className="num">{s.nb_plateformes > 0 ? s.nb_plateformes : <span className="text-muted">—</span>}</td>
<td className="num">{s.nb_investissements > 0 ? s.nb_investissements : <span className="text-muted">—</span>}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Accordéon — Mes propres secteurs */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<button type="button"
onClick={() => setSectPrivateOpen(o => !o)}
style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 8,
background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--text)', textAlign: 'left' }}>
<span style={{ fontWeight: 600, fontSize: 'var(--fs-base)' }}>
Mes propres secteurs
<span style={{ marginLeft: 8, fontWeight: 400, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
({privateSects.length})
</span>
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
style={{ transform: sectPrivateOpen ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{sectPrivateOpen && (
<button className="primary" type="button" style={{ marginLeft: 12, flexShrink: 0 }}
onClick={() => { setSectPrivateOpen(true); setShowNewSectInv(true); setNewSectInvNom(''); setErr(null); }}>
+ Ajouter
</button>
)}
</div>
{sectPrivateOpen && (
<div style={{ marginTop: 14 }}>
{showNewSectInv && (
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<input autoFocus style={{ flex: 1 }} placeholder="Nom du secteur"
value={newSectInvNom} onChange={e => setNewSectInvNom(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') saveSectInv(newSectInvNom); if (e.key === 'Escape') setShowNewSectInv(false); }} />
<button className="primary" onClick={() => saveSectInv(newSectInvNom)}>Créer</button>
<button onClick={() => setShowNewSectInv(false)}>Annuler</button>
</div>
)}
<table>
<thead>
<tr>
<th>Nom</th>
<th className="num">Plateformes</th>
<th className="num">Investissements</th>
<th style={{ width: 80 }}></th>
</tr>
</thead>
<tbody>
{privateSects.length === 0 && (
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucun secteur personnel</td></tr>
)}
{privateSects.map(s => (
<tr key={s.id}>
<td>
{editingSectInv === s.id ? (
<div style={{ display: 'flex', gap: 6 }}>
<input autoFocus style={{ flex: 1 }} value={editingNomSectInv}
onChange={e => setEditingNomSectInv(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') renameSectInv(s.id, editingNomSectInv); if (e.key === 'Escape') setEditingSectInv(null); }} />
<button className="primary" onClick={() => renameSectInv(s.id, editingNomSectInv)}>OK</button>
<button onClick={() => setEditingSectInv(null)}>✕</button>
</div>
) : (
<span style={{ fontWeight: 600 }}>{s.nom}</span>
)}
</td>
<td className="num">{s.nb_plateformes > 0 ? s.nb_plateformes : <span className="text-muted">—</span>}</td>
<td className="num">{s.nb_investissements > 0 ? s.nb_investissements : <span className="text-muted">—</span>}</td>
<td>
{editingSectInv !== s.id && (
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
<button style={{ fontSize: 12, padding: '2px 8px' }}
onClick={() => { setEditingSectInv(s.id); setEditingNomSectInv(s.nom); setErr(null); }}>
Renommer
</button>
<button style={{ fontSize: 12, padding: '2px 8px', color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => setConfirmDelete({
title: 'Supprimer le secteur',
message: `Supprimer le secteur "${s.nom}" ? Il sera retiré de toutes les plateformes et investissements.`,
confirmLabel: 'Supprimer',
onConfirm: () => { delSectInv(s.id); setConfirmDelete(null); }
})}>
Supprimer
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}