Initial commit
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext.jsx';
|
||||
import UsersSection from './admin/UsersSection.jsx';
|
||||
import CreateUserSection from './admin/CreateUserSection.jsx';
|
||||
import JobLogsSection from './admin/JobLogsSection.jsx';
|
||||
import IconsSection from './admin/IconsSection.jsx';
|
||||
|
||||
/* ── Icônes nav ───────────────────────────────────────────────── */
|
||||
function IconUsers() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>; }
|
||||
function IconUserPlus() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>; }
|
||||
function IconActivity() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>; }
|
||||
function IconImage() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>; }
|
||||
function IconTax() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>; }
|
||||
function IconDatabase() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>; }
|
||||
|
||||
const NAV = [
|
||||
{
|
||||
group: 'Administration de la plateforme',
|
||||
items: [
|
||||
{ id: 'users', label: 'Utilisateurs', icon: <IconUsers /> },
|
||||
{ id: 'create', label: 'Créer un utilisateur', icon: <IconUserPlus /> },
|
||||
{ id: 'job-logs', label: 'Logs des jobs', icon: <IconActivity /> },
|
||||
{ id: 'icons', label: "Bibliothèque d'icônes", icon: <IconImage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Référentiels',
|
||||
items: [
|
||||
{ id: 'plateformes', label: 'Plateformes', icon: <IconDatabase />, href: '/admin/plateformes' },
|
||||
{ id: 'fiscalite', label: 'Fiscalité', icon: <IconTax />, href: '/admin/fiscalite' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function Admin() {
|
||||
const { search } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const { user } = useAuth();
|
||||
|
||||
const section = new URLSearchParams(search).get('section') || 'users';
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="account-layout">
|
||||
<aside className="account-sidebar">
|
||||
<h1 className="account-title">Administration</h1>
|
||||
{NAV.map(group => (
|
||||
<div key={group.group} className="account-nav-group">
|
||||
<span className="account-nav-label">{group.group}</span>
|
||||
{group.items.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`account-nav-item${section === item.id ? ' active' : ''}`}
|
||||
onClick={() => item.href ? navigate(item.href) : navigate(`/admin?section=${item.id}`, { replace: true })}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
<div className="account-content">
|
||||
{section === 'users' && <UsersSection currentUserId={user?.id} key={refreshKey} />}
|
||||
{section === 'create' && <CreateUserSection onCreated={() => setRefreshKey(k => k + 1)} />}
|
||||
{section === 'job-logs' && <JobLogsSection />}
|
||||
{section === 'icons' && <IconsSection />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,188 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
/* ── Accordéon FAQ ───────────────────────────────────────────── */
|
||||
function FaqItem({ question, children }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div style={{
|
||||
borderBottom: '1px solid var(--border)',
|
||||
padding: '0',
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
style={{
|
||||
width: '100%', textAlign: 'left', background: 'none', border: 'none',
|
||||
padding: '14px 0', cursor: 'pointer', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'space-between', gap: 12,
|
||||
color: 'var(--text)', fontSize: 'var(--fs-base)', fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<span>{question}</span>
|
||||
<svg
|
||||
width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ flexShrink: 0, transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s' }}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{
|
||||
paddingBottom: 16, color: 'var(--text-muted)',
|
||||
fontSize: 'var(--fs-sm)', lineHeight: 1.7,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Navigation ─────────────────────────────────────────────── */
|
||||
const NAV = [
|
||||
{
|
||||
id: 'faq',
|
||||
label: 'FAQ',
|
||||
icon: (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/* ── Page principale ─────────────────────────────────────────── */
|
||||
export default function Aide() {
|
||||
const { search } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const section = new URLSearchParams(search).get('section') || 'faq';
|
||||
const setSection = (s) => navigate(`/aide?section=${s}`, { replace: true });
|
||||
|
||||
return (
|
||||
<div className="account-layout">
|
||||
|
||||
{/* ── Nav gauche ───────────────────────────────────────── */}
|
||||
<aside className="account-sidebar">
|
||||
<h1 className="account-title">Centre d'aide</h1>
|
||||
{NAV.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`account-nav-item${section === item.id ? ' active' : ''}`}
|
||||
onClick={() => setSection(item.id)}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
{/* ── Contenu ──────────────────────────────────────────── */}
|
||||
<div className="account-content">
|
||||
|
||||
{section === 'faq' && (
|
||||
<div>
|
||||
<h2 style={{ marginTop: 0, marginBottom: 24 }}>Questions fréquentes</h2>
|
||||
|
||||
<FaqItem question="Comment est calculé le solde du porte-monnaie d'une plateforme ?">
|
||||
<p style={{ marginTop: 0 }}>
|
||||
Le solde du porte-monnaie représente les liquidités disponibles sur une plateforme,
|
||||
c'est-à-dire l'argent que vous pouvez retirer ou réinvestir. Il est calculé comme suit :
|
||||
</p>
|
||||
|
||||
<div style={{ margin: '12px 0', padding: '12px 16px', background: 'var(--surface-2)', borderRadius: 8, fontFamily: 'monospace', fontSize: 'var(--fs-sm)', color: 'var(--text)', lineHeight: 2 }}>
|
||||
Solde = Dépôts<br />
|
||||
− Retraits manuels<br />
|
||||
+ Remboursements crédités au porte-monnaie<br />
|
||||
+ Bonus (parrainage / plateforme)<br />
|
||||
− Capital investi (en cours ou remboursé)<br />
|
||||
+ Corrections de solde
|
||||
</div>
|
||||
|
||||
<p><strong style={{ color: 'var(--text)' }}>Retraits manuels</strong> — seuls les retraits que vous avez saisis manuellement sont déduits.
|
||||
Les retraits générés automatiquement lors d'un remboursement en mode "compte courant" sont exclus,
|
||||
car ils ne représentent pas un vrai mouvement de porte-monnaie.</p>
|
||||
|
||||
<p><strong style={{ color: 'var(--text)' }}>Remboursements crédités au porte-monnaie</strong> — uniquement les remboursements
|
||||
dont le mode est "Portefeuille" (et non "Compte courant"). Le montant crédité dépend de la fiscalité
|
||||
de la plateforme :</p>
|
||||
<ul style={{ margin: '8px 0 8px 16px', paddingLeft: 0 }}>
|
||||
<li style={{ marginBottom: 6 }}>
|
||||
<strong style={{ color: 'var(--text)' }}>Plateforme française (Flat Tax)</strong> — le porte-monnaie
|
||||
reçoit le <em>net reçu</em>, c'est-à-dire le montant après déduction du PFU français (17,2 % de prélèvements
|
||||
sociaux + 12,8 % d'impôt sur le revenu), prélevé directement à la source par la plateforme.
|
||||
</li>
|
||||
<li style={{ marginBottom: 6 }}>
|
||||
<strong style={{ color: 'var(--text)' }}>Plateforme hors France (sans fiscalité locale)</strong> — le porte-monnaie
|
||||
reçoit le capital remboursé + cashback + intérêts bruts. Le PFU français n'est pas prélevé à la
|
||||
source : vous devez le déclarer séparément dans votre déclaration fiscale annuelle.
|
||||
</li>
|
||||
<li>
|
||||
<strong style={{ color: 'var(--text)' }}>Plateforme hors France (avec retenue à la source locale)</strong> — même
|
||||
principe que ci-dessus, mais la plateforme a déjà prélevé une taxe locale sur les intérêts. Le
|
||||
porte-monnaie reçoit le capital + cashback + intérêts bruts <em>après</em> cette retenue locale.
|
||||
Le PFU français reste à déclarer séparément.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p><strong style={{ color: 'var(--text)' }}>Capital investi</strong> — le montant que vous avez placé dans des prêts actifs
|
||||
(y compris les réinvestissements complémentaires) est soustrait du porte-monnaie, car ces fonds ne sont
|
||||
plus disponibles. Ils reviennent progressivement via les remboursements.</p>
|
||||
|
||||
<p style={{ marginBottom: 0 }}><strong style={{ color: 'var(--text)' }}>Corrections de solde</strong> — ajustements manuels
|
||||
permettant de réconcilier de micro-écarts de calcul (par exemple un arrondi de centimes sur la fiscalité).</p>
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Comment mettre en place un réinvestissement automatique des intérêts ?">
|
||||
<p style={{ marginTop: 0 }}>
|
||||
Le réinvestissement automatique permet de capitaliser les intérêts perçus après chaque remboursement,
|
||||
sans aucune saisie manuelle. Les intérêts sont automatiquement réinjectés dans le capital du prêt,
|
||||
ce qui augmente progressivement le montant investi et les intérêts futurs.
|
||||
</p>
|
||||
|
||||
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Activation</h4>
|
||||
<ol style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
|
||||
<li>Ouvrez la fiche d'un investissement.</li>
|
||||
<li>Cliquez sur le bouton <strong style={{ color: 'var(--text)' }}>⋮</strong> en haut à droite du bloc <em>Informations du projet</em>, puis choisissez <strong style={{ color: 'var(--text)' }}>Réinvestir</strong>.</li>
|
||||
<li>Dans la modale, sélectionnez l'onglet <strong style={{ color: 'var(--text)' }}>Automatique</strong>.</li>
|
||||
<li>Cliquez sur <strong style={{ color: 'var(--text)' }}>Activer</strong>.</li>
|
||||
</ol>
|
||||
<p>
|
||||
Une fois activé, le bloc <em>Réinvestissements complémentaires</em> apparaît sur la fiche avec
|
||||
un badge <strong style={{ color: 'var(--primary)' }}>auto</strong>, même si aucun remboursement
|
||||
n'a encore eu lieu.
|
||||
</p>
|
||||
|
||||
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Quel montant est réinvesti ?</h4>
|
||||
<p>Le montant réinvesti après chaque remboursement dépend de la fiscalité de la plateforme :</p>
|
||||
<ul style={{ margin: '8px 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
|
||||
<li>
|
||||
<strong style={{ color: 'var(--text)' }}>Plateforme française (Flat Tax)</strong> — les <em>intérêts nets</em> sont réinvestis
|
||||
(après déduction du PFU prélevé à la source). C'est le montant réellement reçu sur votre porte-monnaie.
|
||||
</li>
|
||||
<li>
|
||||
<strong style={{ color: 'var(--text)' }}>Plateforme hors France</strong> — les <em>intérêts bruts</em> sont réinvestis,
|
||||
car aucune retenue n'est effectuée à la source. Pensez à provisionner la fiscalité due lors de votre déclaration annuelle.
|
||||
</li>
|
||||
</ul>
|
||||
<p>Si les intérêts d'un remboursement sont nuls (remboursement de capital seul), aucun réinvestissement n'est créé.</p>
|
||||
|
||||
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Désactivation</h4>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
Pour désactiver le réinvestissement automatique, cliquez sur <strong style={{ color: 'var(--text)' }}>⋮</strong> dans le bloc
|
||||
<em> Informations du projet</em> et choisissez <strong style={{ color: 'var(--text)' }}>Désactiver le réinvestissement auto</strong>.
|
||||
Les réinvestissements déjà créés sont conservés ; les prochains remboursements n'en génèreront plus.
|
||||
</p>
|
||||
</FaqItem>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
import { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import PageIcon from '../components/PageIcon.jsx';
|
||||
import { api } from '../api.js';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
import { useUi } from '../context/UiContext.jsx';
|
||||
import { fmtEUR, fmtPct, fmtDate, fmtStatut, memberLabel } from '../utils/format.js';
|
||||
import InteretsMensuelsChart from '../components/InteretsMensuelsChart.jsx';
|
||||
import InteretsDonutChart from '../components/InteretsDonutChart.jsx';
|
||||
import { InteretsChartProvider, useInteretsChart } from '../context/InteretsChartContext.jsx';
|
||||
import TableauInteretsPlateforme from '../components/TableauInteretsPlateforme.jsx';
|
||||
import DrillCellPanel from '../components/DrillCellPanel.jsx';
|
||||
|
||||
/* ── Sélecteur d'année — doit être enfant de InteretsChartProvider ── */
|
||||
function YearSelectorKpi() {
|
||||
const { annee, setAnnee, availableYears, modeGlobal, toggleModeGlobal } = useInteretsChart();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
const handleSelect = (value) => {
|
||||
if (value === 'all') {
|
||||
if (!modeGlobal) toggleModeGlobal();
|
||||
} else {
|
||||
if (modeGlobal) toggleModeGlobal();
|
||||
setAnnee(value);
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
// Années récentes en premier (desc), puis "Depuis le début" en tête
|
||||
const options = [
|
||||
{ value: 'all', label: 'Depuis le début' },
|
||||
...availableYears.map(y => ({ value: y, label: String(y) })),
|
||||
];
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', flexShrink: 0, width: 200 }}>
|
||||
<div
|
||||
onClick={() => setOpen(v => !v)}
|
||||
style={{
|
||||
height: '100%', boxSizing: 'border-box',
|
||||
background: 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)',
|
||||
borderRadius: 10,
|
||||
padding: '16px 20px',
|
||||
boxShadow: open
|
||||
? '0 6px 28px rgba(109,40,217,0.45)'
|
||||
: '0 4px 20px rgba(109,40,217,0.30)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
|
||||
userSelect: 'none',
|
||||
transition: 'box-shadow .15s',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{
|
||||
fontSize: 'var(--fs-xs)', textTransform: 'uppercase',
|
||||
letterSpacing: '.06em', color: 'rgba(255,255,255,0.7)', fontWeight: 500,
|
||||
}}>
|
||||
Période
|
||||
</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="rgba(255,255,255,0.7)" strokeWidth="2.5"
|
||||
strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{
|
||||
color: '#fff',
|
||||
fontSize: modeGlobal ? '1.1rem' : '2rem',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.1,
|
||||
marginTop: 8,
|
||||
}}>
|
||||
{modeGlobal ? 'Depuis le début' : String(annee)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 200,
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 10, boxShadow: '0 8px 28px rgba(0,0,0,0.15)',
|
||||
minWidth: 200, overflow: 'hidden',
|
||||
}}>
|
||||
{options.map((opt, i) => {
|
||||
const isActive = opt.value === 'all' ? modeGlobal : (!modeGlobal && annee === opt.value);
|
||||
return (
|
||||
<div
|
||||
key={opt.value}
|
||||
onClick={() => handleSelect(opt.value)}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
background: isActive ? 'rgba(109,40,217,0.08)' : 'transparent',
|
||||
color: isActive ? '#7c3aed' : 'var(--text)',
|
||||
fontWeight: isActive ? 700 : 400,
|
||||
fontSize: 'var(--fs-sm)',
|
||||
borderBottom: i < options.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
transition: 'background .1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--surface-2)'; }}
|
||||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<span>{opt.label}</span>
|
||||
{isActive && (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="#7c3aed" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ── Badge de tendance ── */
|
||||
function TrendBadge({ current, prev, invert = false }) {
|
||||
if (prev == null || prev === 0) return null;
|
||||
const diff = current - prev;
|
||||
const pct = (diff / prev) * 100;
|
||||
const up = diff > 0;
|
||||
const neutral = diff === 0;
|
||||
// invert=true : une hausse est mauvaise (ex. capital en risque)
|
||||
const good = neutral ? null : (invert ? !up : up);
|
||||
const color = neutral ? 'var(--text-muted)' : good ? '#16a34a' : '#dc2626';
|
||||
const bg = neutral ? 'var(--surface-2)' : good ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)';
|
||||
const arrow = neutral ? '→' : up ? '↗' : '↘';
|
||||
const label = `${up ? '+' : ''}${Math.abs(pct) < 10 ? pct.toFixed(1) : Math.round(pct)}%`;
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
padding: '2px 8px', borderRadius: 20,
|
||||
background: bg, color, fontSize: '0.76em', fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{arrow} {label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Carte KPI individuelle ── */
|
||||
function KpiCard({ title, value, badge, refValue, onClick }) {
|
||||
return (
|
||||
<div
|
||||
className="kpi"
|
||||
onClick={onClick}
|
||||
style={onClick ? { cursor: 'pointer', transition: 'box-shadow 0.15s, opacity 0.15s' } : undefined}
|
||||
onMouseEnter={onClick ? (e) => { e.currentTarget.style.boxShadow = '0 0 0 2px var(--primary)'; e.currentTarget.style.opacity = '0.88'; } : undefined}
|
||||
onMouseLeave={onClick ? (e) => { e.currentTarget.style.boxShadow = ''; e.currentTarget.style.opacity = ''; } : undefined}
|
||||
>
|
||||
<div className="label">{title}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{value}</span>
|
||||
{badge}
|
||||
</div>
|
||||
{refValue && (
|
||||
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{refValue}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── KPI filtrés par année ── */
|
||||
function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData }) {
|
||||
const {
|
||||
annee, modeGlobal, rawDataGlobal, rawData, currentYear, currentMonth,
|
||||
setInclureInterets, setInclureCapital, setInclureCashback,
|
||||
} = useInteretsChart();
|
||||
|
||||
const isCurrentYear = !modeGlobal && Number(annee) === currentYear;
|
||||
const isFutureYear = !modeGlobal && Number(annee) > currentYear;
|
||||
|
||||
// Capital de référence pour l'année sélectionnée
|
||||
// Pour les années passées : dernier mois de capitalMensuel (= capital déployé fin décembre)
|
||||
// Pour l'année courante / mode global : capital actuellement déployé (portfolio)
|
||||
const capitalAnnee = useMemo(() => {
|
||||
if (modeGlobal || isCurrentYear) return portfolio.encours + portfolio.en_defaut;
|
||||
// Année passée : prendre la valeur de fin décembre depuis capitalMensuel
|
||||
const anneeStr = String(annee);
|
||||
const moisAnnee = (capitalMensuelData ?? []).filter(c => c.mois?.startsWith(anneeStr));
|
||||
if (moisAnnee.length > 0) {
|
||||
// Prendre le dernier mois disponible (normalement décembre)
|
||||
const last = moisAnnee[moisAnnee.length - 1];
|
||||
return last.capital ?? 0;
|
||||
}
|
||||
return portfolio.encours + portfolio.en_defaut;
|
||||
}, [modeGlobal, isCurrentYear, annee, capitalMensuelData, portfolio]);
|
||||
|
||||
// Capital souscrit par année (pour référence performance N-1, données manquantes, etc.)
|
||||
const capitalParAnneeMap = useMemo(() => {
|
||||
const list = rawDataGlobal.capitalParAnnee ?? [];
|
||||
return Object.fromEntries(list.map(r => [r.annee, r.capital_souscrit]));
|
||||
}, [rawDataGlobal]);
|
||||
|
||||
// ── Estimation réduction PFU pour une année donnée ──
|
||||
const getPfuReduction = (yr) => {
|
||||
if (!pfuRates.length) return 0;
|
||||
const rate = pfuRates.find(r => r.annee === yr)
|
||||
?? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]);
|
||||
return (rate.prelev_sociaux + rate.impot_revenu) / 100;
|
||||
};
|
||||
|
||||
// ── Données consolidées pour une année (actuel + projeté selon le cas) ──
|
||||
const getYearData = (yr) => {
|
||||
const remRow = (rawDataGlobal.rembourses ?? []).find(r => Number(r.annee) === yr);
|
||||
const projRow = (rawDataGlobal.projections ?? []).find(r => Number(r.annee) === yr);
|
||||
if (yr > currentYear) {
|
||||
// Année future : projections uniquement
|
||||
const bruts = projRow?.interets_prevus || 0;
|
||||
const red = getPfuReduction(yr);
|
||||
return { interets_bruts: bruts, interets_nets: bruts * (1 - red), capital: projRow?.capital_prevu || 0, cashback: 0 };
|
||||
}
|
||||
if (yr === currentYear) {
|
||||
// Année en cours : actuel reçu + reste projeté
|
||||
const bruts_act = remRow?.interets_bruts || 0;
|
||||
const bruts_proj = projRow?.interets_prevus || 0;
|
||||
const red = getPfuReduction(yr);
|
||||
return {
|
||||
interets_bruts: bruts_act + bruts_proj,
|
||||
interets_nets: (remRow?.interets_nets || 0) + bruts_proj * (1 - red),
|
||||
capital: (remRow?.capital || 0) + (projRow?.capital_prevu || 0),
|
||||
cashback: remRow?.cashback || 0,
|
||||
};
|
||||
}
|
||||
// Année passée : actuel uniquement
|
||||
return {
|
||||
interets_bruts: remRow?.interets_bruts || 0,
|
||||
interets_nets: remRow?.interets_nets || 0,
|
||||
capital: remRow?.capital || 0,
|
||||
cashback: remRow?.cashback || 0,
|
||||
};
|
||||
};
|
||||
|
||||
// ── Données mensuelles (mois en cours + M-1) — uniquement si année courante ──
|
||||
const { thisMonthRow, prevMonthRow, prevMonthLabel, capitalCurrent, capitalPrev, enDefautCurrent, enDefautPrev } = useMemo(() => {
|
||||
const MOIS_FR = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.'];
|
||||
const rows = rawData.rembourses ?? [];
|
||||
const thisStr = String(currentYear) + '-' + String(currentMonth).padStart(2, '0');
|
||||
const curr = rows.find(r => r.mois === thisStr) ?? null;
|
||||
const prevDate = new Date(currentYear, currentMonth - 2, 1);
|
||||
const prevStr = String(prevDate.getFullYear()) + '-' + String(prevDate.getMonth() + 1).padStart(2, '0');
|
||||
const prev = rows.find(r => r.mois === prevStr) ?? null;
|
||||
const label = MOIS_FR[prevDate.getMonth()] + ' ' + prevDate.getFullYear();
|
||||
|
||||
// Capital investi et en risque M vs M-1 depuis capitalMensuelData
|
||||
const capRows = capitalMensuelData ?? [];
|
||||
const capCurr = capRows.find(c => c.mois === thisStr);
|
||||
const capPrev = capRows.find(c => c.mois === prevStr);
|
||||
|
||||
return {
|
||||
thisMonthRow: curr, prevMonthRow: prev, prevMonthLabel: label,
|
||||
capitalCurrent: capCurr?.capital ?? null,
|
||||
capitalPrev: capPrev?.capital ?? null,
|
||||
enDefautCurrent: capCurr?.en_defaut ?? null,
|
||||
enDefautPrev: capPrev?.en_defaut ?? null,
|
||||
};
|
||||
}, [rawData, currentYear, currentMonth, capitalMensuelData]);
|
||||
|
||||
// ── Données annuelles (année sélectionnée + N-1) ──
|
||||
const { annualData, prevAnnualData } = useMemo(() => {
|
||||
if (modeGlobal) {
|
||||
const total = (rawDataGlobal.rembourses ?? []).reduce((acc, r) => ({
|
||||
interets_bruts: acc.interets_bruts + (r.interets_bruts || 0),
|
||||
interets_nets: acc.interets_nets + (r.interets_nets || 0),
|
||||
capital: acc.capital + (r.capital || 0),
|
||||
cashback: acc.cashback + (r.cashback || 0),
|
||||
}), { interets_bruts: 0, interets_nets: 0, capital: 0, cashback: 0 });
|
||||
return { annualData: total, prevAnnualData: null };
|
||||
}
|
||||
return {
|
||||
annualData: getYearData(Number(annee)),
|
||||
prevAnnualData: getYearData(Number(annee) - 1),
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rawDataGlobal, annee, modeGlobal, pfuRates]);
|
||||
|
||||
// ── Valeur affichée ──
|
||||
const getValue = (field) => {
|
||||
if (isCurrentYear) return thisMonthRow?.[field] || 0;
|
||||
return annualData[field] || 0;
|
||||
};
|
||||
|
||||
// ── Badge et référence ──
|
||||
const mkBadge = (field) => {
|
||||
if (modeGlobal) return null;
|
||||
if (isCurrentYear) {
|
||||
if (!thisMonthRow || !prevMonthRow) return null;
|
||||
return <TrendBadge current={thisMonthRow[field] || 0} prev={prevMonthRow[field] || 0} />;
|
||||
}
|
||||
if (!prevAnnualData) return null;
|
||||
return <TrendBadge current={annualData[field] || 0} prev={prevAnnualData[field] || 0} />;
|
||||
};
|
||||
|
||||
const mkRef = (field) => {
|
||||
if (modeGlobal) return null;
|
||||
if (isCurrentYear) {
|
||||
if (!prevMonthRow) return null;
|
||||
return fmtEUR(prevMonthRow[field] || 0) + ' en ' + prevMonthLabel;
|
||||
}
|
||||
if (!prevAnnualData) return null;
|
||||
const prevYr = Number(annee) - 1;
|
||||
const suffix = prevYr >= currentYear ? ' (proj.)' : '';
|
||||
return fmtEUR(prevAnnualData[field] || 0) + ' en ' + prevYr + suffix;
|
||||
};
|
||||
|
||||
const interetsField = netMode ? 'interets_nets' : 'interets_bruts';
|
||||
|
||||
// ── Performance annualisée ──────────────────────────────────────
|
||||
const calcPerfRatio = (interetsVal, capital) =>
|
||||
capital > 0 && interetsVal != null ? interetsVal / capital : null;
|
||||
|
||||
// Mensuelle annualisée (mode année courante)
|
||||
const capitalDeploye = portfolio.encours + portfolio.en_defaut;
|
||||
|
||||
const perfCurrent = isCurrentYear && thisMonthRow && capitalDeploye > 0
|
||||
? calcPerfRatio(thisMonthRow[interetsField] || 0, capitalDeploye) * 12
|
||||
: null;
|
||||
const perfPrev = isCurrentYear && prevMonthRow && capitalDeploye > 0
|
||||
? calcPerfRatio(prevMonthRow[interetsField] || 0, capitalDeploye) * 12
|
||||
: null;
|
||||
|
||||
// Annuelle (mode année passée/future) — même formule que le tableau :
|
||||
// (interets + cashback) / capital souscrit cette année-là
|
||||
const perfInteretsAnnee = (annualData[interetsField] || 0) + (annualData.cashback || 0);
|
||||
const perfAnnual = !modeGlobal && !isCurrentYear && capitalAnnee > 0
|
||||
? calcPerfRatio(perfInteretsAnnee, capitalAnnee)
|
||||
: null;
|
||||
const prevCapitalAnnee = modeGlobal || isCurrentYear
|
||||
? capitalDeploye
|
||||
: (capitalParAnneeMap[Number(annee) - 1] ?? capitalDeploye);
|
||||
const perfInteretsPrevAnnee = ((prevAnnualData?.[interetsField] || 0) + (prevAnnualData?.cashback || 0));
|
||||
const perfAnnualPrev = !modeGlobal && !isCurrentYear && prevAnnualData && prevCapitalAnnee > 0
|
||||
? calcPerfRatio(perfInteretsPrevAnnee, prevCapitalAnnee)
|
||||
: null;
|
||||
|
||||
const perfValue = modeGlobal ? null : (isCurrentYear ? perfCurrent : perfAnnual);
|
||||
const perfPrevVal = modeGlobal ? null : (isCurrentYear ? perfPrev : perfAnnualPrev);
|
||||
|
||||
const perfLabel = netMode ? 'Performance nette annualisée' : 'Performance brute annualisée';
|
||||
const perfRefLabel = isCurrentYear
|
||||
? (perfPrevVal != null ? fmtPct(perfPrevVal * 100) + ' en ' + prevMonthLabel : null)
|
||||
: (perfAnnualPrev != null ? fmtPct(perfAnnualPrev * 100) + ' en ' + (Number(annee) - 1) : null);
|
||||
|
||||
return (
|
||||
<div className="kpi-grid" style={{ flex: 1, marginBottom: 0 }}>
|
||||
<KpiCard
|
||||
title="Capital investi"
|
||||
value={fmtEUR(capitalAnnee)}
|
||||
badge={isCurrentYear && capitalCurrent != null && capitalPrev != null && capitalPrev > 0
|
||||
? <TrendBadge current={capitalCurrent} prev={capitalPrev} />
|
||||
: null}
|
||||
refValue={isCurrentYear && capitalPrev != null
|
||||
? fmtEUR(capitalPrev) + ' en ' + prevMonthLabel
|
||||
: null}
|
||||
/>
|
||||
<KpiCard
|
||||
title="Capital en risque"
|
||||
value={fmtEUR(portfolio.en_defaut)}
|
||||
badge={isCurrentYear && enDefautCurrent != null && enDefautPrev != null && enDefautPrev > 0
|
||||
? <TrendBadge current={enDefautCurrent} prev={enDefautPrev} invert={true} />
|
||||
: null}
|
||||
refValue={isCurrentYear && enDefautPrev != null && enDefautPrev > 0
|
||||
? fmtEUR(enDefautPrev) + ' en ' + prevMonthLabel
|
||||
: null}
|
||||
/>
|
||||
<KpiCard
|
||||
title={perfLabel + (isFutureYear ? ' (proj.)' : '')}
|
||||
value={perfValue != null ? fmtPct(perfValue * 100) : '—'}
|
||||
badge={perfValue != null && perfPrevVal != null
|
||||
? <TrendBadge current={perfValue} prev={perfPrevVal} />
|
||||
: null}
|
||||
refValue={perfRefLabel}
|
||||
/>
|
||||
<KpiCard
|
||||
title={(netMode ? 'Intérêts nets' : 'Intérêts bruts') + (isFutureYear ? ' (proj.)' : '')}
|
||||
value={fmtEUR(getValue(interetsField))}
|
||||
badge={mkBadge(interetsField)}
|
||||
refValue={mkRef(interetsField)}
|
||||
onClick={() => { setInclureInterets(true); setInclureCapital(false); setInclureCashback(false); }}
|
||||
/>
|
||||
<KpiCard
|
||||
title={'Capital remboursé' + (isFutureYear ? ' (proj.)' : '')}
|
||||
value={fmtEUR(getValue('capital'))}
|
||||
badge={mkBadge('capital')}
|
||||
refValue={mkRef('capital')}
|
||||
onClick={() => { setInclureInterets(false); setInclureCapital(true); setInclureCashback(false); }}
|
||||
/>
|
||||
<KpiCard
|
||||
title="Cashback reçu"
|
||||
value={fmtEUR(getValue('cashback'))}
|
||||
badge={mkBadge('cashback')}
|
||||
refValue={mkRef('cashback')}
|
||||
onClick={() => { setInclureInterets(false); setInclureCapital(false); setInclureCashback(true); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default function Dashboard() {
|
||||
const { activeId, activeView, activeViewMember, investisseurs } = useInvestisseur();
|
||||
const { displayMode } = useUi();
|
||||
const netMode = displayMode === 'net';
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [data, setData] = useState(null);
|
||||
const [pfuRates, setPfuRates] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [capitalMensuelData, setCapitalMensuelData] = useState([]);
|
||||
const [plateformes, setPlateformes] = useState([]);
|
||||
|
||||
/* ── drillCell : cellule sélectionnée dans le TIP — par défaut mois courant toutes plateformes ── */
|
||||
const _now = new Date();
|
||||
const _moisLabels = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||||
|
||||
/* Restaure le drillCell depuis les params URL (retour depuis Remboursements) */
|
||||
const _initDrillCell = () => {
|
||||
const ba = searchParams.get('drill-annee');
|
||||
const bm = searchParams.get('drill-mois');
|
||||
const bp = searchParams.get('drill-plat');
|
||||
if (ba && bm) {
|
||||
const annee = Number(ba), mois = Number(bm);
|
||||
return {
|
||||
platId: bp ? Number(bp) : null,
|
||||
platNom: null,
|
||||
annee,
|
||||
mois,
|
||||
moisLabel: _moisLabels[mois - 1],
|
||||
};
|
||||
}
|
||||
return {
|
||||
platId: null,
|
||||
platNom: null,
|
||||
annee: _now.getFullYear(),
|
||||
mois: _now.getMonth() + 1,
|
||||
moisLabel: _moisLabels[_now.getMonth()],
|
||||
};
|
||||
};
|
||||
const [drillCell, setDrillCell] = useState(_initDrillCell);
|
||||
|
||||
/* Nettoie les params URL de retour dès le premier rendu */
|
||||
useEffect(() => {
|
||||
if (searchParams.get('drill-annee')) {
|
||||
setSearchParams({}, { replace: true });
|
||||
}
|
||||
}, []); /* eslint-disable-next-line */
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/pfu').then(setPfuRates).catch(() => {});
|
||||
api.get('/plateformes').then(setPlateformes).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeId && activeView !== 'all') return;
|
||||
setData(null);
|
||||
setLoading(true);
|
||||
const params = activeView === 'all' ? { scope: 'all' } : undefined;
|
||||
api.get('/dashboard', params).then(setData).finally(() => setLoading(false));
|
||||
}, [activeView, activeId]);
|
||||
|
||||
const ready = activeView === 'all' ? investisseurs.length > 0 : !!activeId;
|
||||
if (!ready) return <div className="card text-muted">Sélectionnez un compte investisseur.</div>;
|
||||
if (loading || !data) return <div className="card text-muted">Chargement…</div>;
|
||||
|
||||
const { cash, cashByPlatform, portfolio, interets, interetsParAnnee } = data;
|
||||
const viewTitle = activeView === 'all'
|
||||
? 'Famille et entreprises'
|
||||
: memberLabel(activeViewMember);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar"><h2><PageIcon name="dashboard" />Tableau de bord — {viewTitle}</h2></div>
|
||||
|
||||
<InteretsChartProvider netMode={netMode} pfuRates={pfuRates} activeView={activeView} activeId={activeId}>
|
||||
|
||||
{/* ── KPI + sélecteur année ── */}
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'stretch', marginBottom: 16 }}>
|
||||
<DashboardKpis portfolio={portfolio} netMode={netMode} pfuRates={pfuRates} capitalMensuelData={capitalMensuelData} />
|
||||
<YearSelectorKpi />
|
||||
</div>
|
||||
|
||||
{/* ── Graphiques ── */}
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'stretch', marginTop: 8, marginBottom: 24 }}>
|
||||
<div style={{ flex: 2, minWidth: 0 }}>
|
||||
<InteretsMensuelsChart />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<InteretsDonutChart />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Tableau intérêts par plateforme ── */}
|
||||
<div style={{ marginBottom: 0 }}>
|
||||
<TableauInteretsPlateforme
|
||||
activeView={activeView}
|
||||
activeId={activeId}
|
||||
pfuRates={pfuRates}
|
||||
onCapitalMensuel={setCapitalMensuelData}
|
||||
onCellClick={({ platId, platNom, annee, mois, moisLabel }) =>
|
||||
setDrillCell({ platId, platNom, annee, mois, moisLabel })
|
||||
}
|
||||
activeCell={drillCell}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Panneau détail mois / plateforme (remplace les Échéances prévues) ── */}
|
||||
<DrillCellPanel
|
||||
cell={drillCell}
|
||||
alwaysOpen={true}
|
||||
pfuRates={pfuRates}
|
||||
activeView={activeView}
|
||||
activeId={activeId}
|
||||
plateformes={plateformes}
|
||||
investissements={[]}
|
||||
onBulkDone={() => {}}
|
||||
onEditRecu={(r) => {
|
||||
const q = new URLSearchParams({
|
||||
'edit-remb': r.id,
|
||||
from: 'dashboard',
|
||||
'drill-annee': drillCell.annee,
|
||||
'drill-mois': drillCell.mois,
|
||||
...(drillCell.platId ? { 'drill-plat': drillCell.platId } : {}),
|
||||
});
|
||||
navigate(`/remboursements?${q}`);
|
||||
}}
|
||||
onEditProjet={(p) => {
|
||||
const q = new URLSearchParams({
|
||||
'open-simul': p.investissement_id,
|
||||
'simul-date': p.date_prevue,
|
||||
'simul-capital': p.capital_prevu ?? 0,
|
||||
'simul-interets': p.interets_prevus ?? 0,
|
||||
from: 'dashboard',
|
||||
'drill-annee': drillCell.annee,
|
||||
'drill-mois': drillCell.mois,
|
||||
...(drillCell.platId ? { 'drill-plat': drillCell.platId } : {}),
|
||||
});
|
||||
navigate(`/remboursements?${q}`);
|
||||
}}
|
||||
/>
|
||||
|
||||
</InteretsChartProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,330 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { memberInitials, memberDisplayName } from '../utils/format.js';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
import Modal from '../components/Modal.jsx';
|
||||
import ConfirmModal from '../components/ConfirmModal.jsx';
|
||||
|
||||
/* ── Avatar ─────────────────────────────────────────────────── */
|
||||
function MemberAvatar({ membre, size = 40 }) {
|
||||
const initials = memberInitials(membre);
|
||||
const bg = membre.type === 'entreprise'
|
||||
? 'linear-gradient(135deg, #4f46e5 0%, #3730a3 100%)'
|
||||
: 'linear-gradient(135deg, #1e40af 0%, #1e3a8a 100%)';
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: bg,
|
||||
color: 'white',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontWeight: 700, fontSize: Math.round(size * 0.35),
|
||||
flexShrink: 0, letterSpacing: '.03em', userSelect: 'none',
|
||||
}}>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Menu "···" par membre ──────────────────────────────────── */
|
||||
function MemberMenu({ onEdit, onDelete, isPrincipal, isOnly }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const h = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
|
||||
document.addEventListener('mousedown', h);
|
||||
return () => document.removeEventListener('mousedown', h);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button className="member-dots-btn" onClick={() => setOpen(o => !o)}
|
||||
aria-label="Actions" aria-haspopup="menu">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="member-dots-menu" role="menu">
|
||||
<button className="member-dots-menu-item" role="menuitem" onClick={() => { setOpen(false); onEdit(); }}>
|
||||
Modifier
|
||||
</button>
|
||||
{!isPrincipal && (
|
||||
<button className="member-dots-menu-item danger-item" role="menuitem"
|
||||
disabled={isOnly}
|
||||
title={isOnly ? 'Impossible de supprimer le dernier profil' : ''}
|
||||
onClick={() => { setOpen(false); onDelete(); }}>
|
||||
Supprimer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Ligne "Ajouter" ─────────────────────────────────────────── */
|
||||
function AddRow({ label, onClick }) {
|
||||
return (
|
||||
<div className="member-add-row" onClick={onClick} role="button" tabIndex={0}
|
||||
onKeyDown={e => e.key === 'Enter' && onClick()}>
|
||||
<div className="member-add-circle">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Composant principal ─────────────────────────────────────── */
|
||||
export default function FamilleEntreprises() {
|
||||
const { reload: reloadCtx } = useInvestisseur();
|
||||
const [membres, setMembres] = useState([]);
|
||||
const [tab, setTab] = useState('famille');
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
/* Modals */
|
||||
const [modalFamille, setModalFamille] = useState(false);
|
||||
const [modalEntreprise, setModalEntreprise] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState(null); // membre à éditer
|
||||
|
||||
/* Formulaires */
|
||||
const emptyFam = { prenom: '', nom_famille: '' };
|
||||
const emptyEnt = { nom: '', type_fiscal: 'PM' };
|
||||
const [famForm, setFamForm] = useState(emptyFam);
|
||||
const [entForm, setEntForm] = useState(emptyEnt);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(null);
|
||||
|
||||
const load = async () => {
|
||||
const list = await api.get('/investisseurs');
|
||||
setMembres(list);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const famille = membres.filter(m => m.type === 'famille');
|
||||
const entreprises = membres.filter(m => m.type === 'entreprise');
|
||||
const totalCount = membres.length;
|
||||
|
||||
/* ── Ouvrir modal édition ─────────────────────────────────── */
|
||||
const openEdit = (m) => {
|
||||
setEditTarget(m);
|
||||
if (m.type === 'famille') {
|
||||
const restNom = m.prenom ? m.nom.replace(m.prenom, '').trim() : m.nom;
|
||||
setFamForm({ prenom: m.prenom || '', nom_famille: restNom });
|
||||
setModalFamille(true);
|
||||
} else {
|
||||
setEntForm({ nom: m.nom, type_fiscal: m.type_fiscal || 'PM' });
|
||||
setModalEntreprise(true);
|
||||
}
|
||||
};
|
||||
|
||||
const closeModals = () => {
|
||||
setModalFamille(false); setModalEntreprise(false);
|
||||
setEditTarget(null);
|
||||
setFamForm(emptyFam); setEntForm(emptyEnt);
|
||||
setErr(null);
|
||||
};
|
||||
|
||||
/* ── Sauvegarde famille ────────────────────────────────────── */
|
||||
const saveFamille = async (e) => {
|
||||
e.preventDefault(); setErr(null); setSaving(true);
|
||||
try {
|
||||
const fullName = [famForm.prenom.trim(), famForm.nom_famille.trim()].filter(Boolean).join(' ');
|
||||
if (!fullName) throw new Error('Veuillez renseigner au moins un prénom ou un nom.');
|
||||
const payload = {
|
||||
nom: fullName,
|
||||
prenom: famForm.prenom.trim() || null,
|
||||
type: 'famille',
|
||||
type_fiscal: 'PP',
|
||||
};
|
||||
if (editTarget) {
|
||||
await api.put(`/investisseurs/${editTarget.id}`, payload);
|
||||
} else {
|
||||
await api.post('/investisseurs', payload);
|
||||
}
|
||||
await load(); await reloadCtx();
|
||||
closeModals();
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
/* ── Sauvegarde entreprise ─────────────────────────────────── */
|
||||
const saveEntreprise = async (e) => {
|
||||
e.preventDefault(); setErr(null); setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
nom: entForm.nom.trim(),
|
||||
prenom: null,
|
||||
type: 'entreprise',
|
||||
type_fiscal: entForm.type_fiscal,
|
||||
};
|
||||
if (editTarget) {
|
||||
await api.put(`/investisseurs/${editTarget.id}`, payload);
|
||||
} else {
|
||||
await api.post('/investisseurs', payload);
|
||||
}
|
||||
await load(); await reloadCtx();
|
||||
closeModals();
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
/* ── Suppression ──────────────────────────────────────────── */
|
||||
const deleteMembre = (m) => {
|
||||
setDeleteConfirm({
|
||||
message: `Supprimer "${memberDisplayName(m)}" ? Tous les investissements associés seront effacés.`,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await api.del(`/investisseurs/${m.id}`);
|
||||
await load(); await reloadCtx();
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setDeleteConfirm(null); }
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/* ── Render ───────────────────────────────────────────────── */
|
||||
const currentList = tab === 'famille' ? famille : entreprises;
|
||||
|
||||
return (
|
||||
<div className="famille-wrap">
|
||||
{/* Tabs */}
|
||||
<div className="famille-tabs">
|
||||
<button
|
||||
className={`famille-tab${tab === 'famille' ? ' active' : ''}`}
|
||||
onClick={() => setTab('famille')}
|
||||
>
|
||||
Famille
|
||||
<span className="famille-tab-count">{famille.length}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`famille-tab${tab === 'entreprise' ? ' active' : ''}`}
|
||||
onClick={() => setTab('entreprise')}
|
||||
>
|
||||
Entreprises
|
||||
<span className="famille-tab-count">{entreprises.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
|
||||
|
||||
{/* Liste */}
|
||||
<div className="membre-list">
|
||||
{currentList.map(m => (
|
||||
<div key={m.id} className="membre-row">
|
||||
<MemberAvatar membre={m} size={42} />
|
||||
<div className="membre-info">
|
||||
<span className="membre-name">{memberDisplayName(m)}</span>
|
||||
{m.type === 'famille' && (
|
||||
<span className="membre-role">
|
||||
{m.is_principal ? '(compte principal)' : '(Membre de la famille)'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{m.type_fiscal && m.type === 'entreprise' && (
|
||||
<span className="membre-badge">{m.type_fiscal}</span>
|
||||
)}
|
||||
<MemberMenu
|
||||
onEdit={() => openEdit(m)}
|
||||
onDelete={() => deleteMembre(m)}
|
||||
isPrincipal={!!m.is_principal}
|
||||
isOnly={totalCount <= 1}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Ligne d'ajout */}
|
||||
{tab === 'famille' && (
|
||||
<AddRow label="Ajouter une personne"
|
||||
onClick={() => { setEditTarget(null); setFamForm(emptyFam); setModalFamille(true); }} />
|
||||
)}
|
||||
{tab === 'entreprise' && (
|
||||
<AddRow label="Ajouter une entreprise"
|
||||
onClick={() => { setEditTarget(null); setEntForm(emptyEnt); setModalEntreprise(true); }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Modal famille ──────────────────────────────────────── */}
|
||||
<Modal
|
||||
open={modalFamille}
|
||||
title={editTarget ? 'Modifier le membre' : 'Ajouter une personne'}
|
||||
onClose={closeModals}
|
||||
footer={
|
||||
<>
|
||||
<button className="ghost" type="button" onClick={closeModals}>Annuler</button>
|
||||
<button className="primary" form="form-famille" type="submit" disabled={saving}>
|
||||
{saving ? '…' : 'Enregistrer'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="form-famille" onSubmit={saveFamille}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="modal-field">
|
||||
<label>Prénom</label>
|
||||
<input autoFocus value={famForm.prenom}
|
||||
onChange={e => setFamForm({ ...famForm, prenom: e.target.value })}
|
||||
placeholder="Olivier" />
|
||||
</div>
|
||||
<div className="modal-field">
|
||||
<label>Nom de famille <span className="text-muted" style={{ fontWeight: 400 }}>(optionnel)</span></label>
|
||||
<input value={famForm.nom_famille}
|
||||
onChange={e => setFamForm({ ...famForm, nom_famille: e.target.value })}
|
||||
placeholder="CROGUENNEC" />
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* ── Modal entreprise ───────────────────────────────────── */}
|
||||
<Modal
|
||||
open={modalEntreprise}
|
||||
title={editTarget ? "Modifier l'entreprise" : 'Ajouter une entreprise'}
|
||||
onClose={closeModals}
|
||||
footer={
|
||||
<>
|
||||
<button className="ghost" type="button" onClick={closeModals}>Annuler</button>
|
||||
<button className="primary" form="form-entreprise" type="submit" disabled={saving}>
|
||||
{saving ? '…' : 'Enregistrer'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="form-entreprise" onSubmit={saveEntreprise}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="modal-field">
|
||||
<label>Nom de l'entreprise *</label>
|
||||
<input autoFocus required value={entForm.nom}
|
||||
onChange={e => setEntForm({ ...entForm, nom: e.target.value })}
|
||||
placeholder="SCI Famille Croguennec" />
|
||||
</div>
|
||||
<div className="modal-field">
|
||||
<label>Forme juridique</label>
|
||||
<select value={entForm.type_fiscal}
|
||||
onChange={e => setEntForm({ ...entForm, type_fiscal: e.target.value })}>
|
||||
<option value="PM">Personne morale</option>
|
||||
<option value="SCI">SCI</option>
|
||||
<option value="SCPI">SCPI</option>
|
||||
<option value="SARL">SARL</option>
|
||||
<option value="SAS">SAS</option>
|
||||
<option value="SA">SA</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
<ConfirmModal
|
||||
open={!!deleteConfirm}
|
||||
message={deleteConfirm?.message}
|
||||
onConfirm={deleteConfirm?.onConfirm}
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
import { fmtDate } from '../utils/format.js';
|
||||
import ResultBanner from '../components/ResultBanner.jsx';
|
||||
|
||||
const MODULES = {
|
||||
depots_retraits: {
|
||||
label: 'Dépôts / Retraits',
|
||||
required: ['date_operation', 'type', 'montant'],
|
||||
optional: ['plateforme_id', 'libelle', 'reference'],
|
||||
needsInvestisseur: true,
|
||||
},
|
||||
investissements: {
|
||||
label: 'Investissements',
|
||||
required: ['nom_projet', 'date_souscription', 'montant_investi'],
|
||||
optional: ['plateforme_id', 'emetteur', 'date_premiere_echeance', 'date_cible', 'taux_interet', 'duree_mois', 'type_remb', 'freq_interets', 'statut', 'reference'],
|
||||
needsInvestisseur: true,
|
||||
},
|
||||
remboursements: {
|
||||
label: 'Remboursements',
|
||||
required: ['investissement_id', 'date_remb'],
|
||||
optional: ['capital', 'interets_bruts', 'prelev_sociaux', 'prelev_forfaitaire', 'net_recu', 'statut'],
|
||||
needsInvestisseur: true,
|
||||
},
|
||||
plateformes: {
|
||||
label: 'Plateformes',
|
||||
required: ['nom'],
|
||||
optional: ['url', 'notes'],
|
||||
needsInvestisseur: false,
|
||||
note: 'Les plateformes dont le nom existe déjà seront ignorées (pas d\'écrasement).',
|
||||
},
|
||||
taux_pfu: {
|
||||
label: 'Flat Tax — Taux PFU',
|
||||
required: ['annee', 'pfu_total', 'impot_revenu', 'prelev_sociaux'],
|
||||
optional: [],
|
||||
needsInvestisseur: false,
|
||||
global: true,
|
||||
note: 'Table de référence globale. Si une année existe déjà, ses taux seront mis à jour (upsert).',
|
||||
},
|
||||
};
|
||||
|
||||
const MODULE_LABEL = {
|
||||
depots_retraits: 'Dépôts / Retraits',
|
||||
investissements: 'Investissements',
|
||||
remboursements: 'Remboursements',
|
||||
plateformes: 'Plateformes',
|
||||
taux_pfu: 'Flat Tax — Taux PFU',
|
||||
};
|
||||
|
||||
export default function Imports() {
|
||||
const { activeId } = useInvestisseur();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ── Import classique (xlsx/csv/json) ──────────────────────────
|
||||
const [module, setModule] = useState('depots_retraits');
|
||||
const [file, setFile] = useState(null);
|
||||
const [preview, setPreview] = useState(null);
|
||||
const [mapping, setMapping] = useState({});
|
||||
const [defaults, setDefaults] = useState({});
|
||||
const [plats, setPlats] = useState([]);
|
||||
const [investissements,setInvestissements] = useState([]);
|
||||
const [history, setHistory] = useState([]);
|
||||
const [result, setResult] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
// ── Import dossier investissement ─────────────────────────────
|
||||
const [dossierFile, setDossierFile] = useState(null);
|
||||
const [dossierPreview, setDossierPreview] = useState(null); // parsed JSON for review
|
||||
const [dossierResult, setDossierResult] = useState(null);
|
||||
const [dossierBusy, setDossierBusy] = useState(false);
|
||||
const [dossierErr, setDossierErr] = useState(null);
|
||||
const dossierInputRef = useRef(null);
|
||||
|
||||
// History + plateformes sont user-scoped → chargement sans activeId
|
||||
useEffect(() => {
|
||||
api.get('/imports/history').then(setHistory).catch(() => {});
|
||||
api.get('/plateformes').then(setPlats).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Investissements sont investisseur-scoped → besoin de activeId
|
||||
useEffect(() => {
|
||||
if (!activeId) return;
|
||||
api.get('/investissements').then(setInvestissements).catch(() => {});
|
||||
}, [activeId]);
|
||||
|
||||
const def = MODULES[module];
|
||||
const allTargets = def ? [...def.required, ...def.optional] : [];
|
||||
const missingInv = def?.needsInvestisseur && !activeId;
|
||||
|
||||
const onPreview = async () => {
|
||||
if (!file) return;
|
||||
setBusy(true); setErr(null); setResult(null);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const r = await api.upload('/imports/preview', fd);
|
||||
setPreview(r);
|
||||
// Auto-map colonnes dont le nom correspond à une cible
|
||||
const auto = {};
|
||||
for (const t of allTargets) {
|
||||
const col = r.headers.find(h => h.toLowerCase().replace(/\W/g, '_') === t);
|
||||
if (col) auto[t] = col;
|
||||
}
|
||||
setMapping(auto);
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const apply = async () => {
|
||||
setBusy(true); setErr(null);
|
||||
try {
|
||||
const r = await api.post('/imports/apply', {
|
||||
tempId: preview.tempId, module, mapping, defaults,
|
||||
originalFilename: file?.name ?? preview.filename,
|
||||
});
|
||||
setResult({
|
||||
ok: true,
|
||||
msg: `✔ Import terminé : ${r.inserted} / ${r.total} lignes insérées${r.skipped > 0 ? `, ${r.skipped} ignorées` : ''}.${r.errors?.length > 0 ? ` (${r.errors.length} avertissement(s))` : ''}`,
|
||||
});
|
||||
setPreview(null); setFile(null); setMapping({}); setDefaults({});
|
||||
api.get('/imports/history').then(setHistory).catch(() => {});
|
||||
// Recharger les plateformes si c'est ce qui vient d'être importé
|
||||
if (module === 'plateformes') {
|
||||
api.get('/plateformes').then(setPlats).catch(() => {});
|
||||
}
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar"><h2>Import Données</h2></div>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>1. Fichier source</h3>
|
||||
<div className="row">
|
||||
<div>
|
||||
<label>Module cible</label>
|
||||
<select value={module} onChange={e => {
|
||||
setModule(e.target.value);
|
||||
setPreview(null); setMapping({}); setResult(null); setErr(null);
|
||||
}}>
|
||||
{Object.entries(MODULES).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ flex: 2 }}>
|
||||
<label>Fichier .xlsx, .csv ou .json</label>
|
||||
<input type="file" accept=".xlsx,.xls,.csv,.json" onChange={e => {
|
||||
setFile(e.target.files[0]);
|
||||
setPreview(null); setResult(null); setErr(null);
|
||||
}} />
|
||||
</div>
|
||||
<div>
|
||||
<button className="primary" onClick={onPreview}
|
||||
disabled={!file || busy || missingInv}>
|
||||
{busy ? '…' : 'Analyser'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Note contextuelle du module sélectionné */}
|
||||
{def?.note && (
|
||||
<div className="import-module-note">
|
||||
{def.global && (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ flexShrink: 0, marginTop: 1, color: 'var(--warning)' }}>
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
)}
|
||||
{def.note}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Avertissement si module investisseur-scoped mais aucun investisseur actif */}
|
||||
{missingInv && (
|
||||
<div className="error" style={{ marginTop: 10 }}>
|
||||
Sélectionnez un investisseur actif avant d'importer ce module.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && <div className="error" style={{ marginTop: 12 }}>{err}</div>}
|
||||
<ResultBanner result={result} onDismiss={() => setResult(null)} style={{ marginTop: 12 }} />
|
||||
</div>
|
||||
|
||||
{preview && (
|
||||
<>
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>2. Mappage des colonnes</h3>
|
||||
<p className="text-muted" style={{ fontSize: 12 }}>
|
||||
Fichier : <strong>{preview.filename}</strong> — feuille <em>{preview.sheetName}</em> — {preview.allRowCount} lignes.
|
||||
{' '}Champs marqués <span style={{ color: 'var(--danger)' }}>*</span> obligatoires.
|
||||
{' '}Si la colonne n'existe pas, fournissez une valeur par défaut.
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Champ cible</th>
|
||||
<th>Colonne Excel</th>
|
||||
<th>Valeur par défaut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allTargets.map(t => {
|
||||
const isReq = def.required.includes(t);
|
||||
return (
|
||||
<tr key={t}>
|
||||
<td>
|
||||
<code style={{ fontSize: 11 }}>{t}</code>
|
||||
{isReq && <span style={{ color: 'var(--danger)' }}> *</span>}
|
||||
</td>
|
||||
<td>
|
||||
<select value={mapping[t] || ''}
|
||||
onChange={e => setMapping({ ...mapping, [t]: e.target.value })}>
|
||||
<option value="">— ignorer —</option>
|
||||
{preview.headers.map(h => <option key={h} value={h}>{h}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{t === 'plateforme_id' ? (
|
||||
<select value={defaults[t] || ''}
|
||||
onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
|
||||
</select>
|
||||
) : t === 'investissement_id' ? (
|
||||
<select value={defaults[t] || ''}
|
||||
onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
{investissements.map(i => <option key={i.id} value={i.id}>{i.nom_projet}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input value={defaults[t] || ''}
|
||||
onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}
|
||||
placeholder={t === 'statut' ? 'ex. en_cours' : t === 'type' ? 'ex. depot' : ''} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={() => { setPreview(null); setMapping({}); }}>Annuler</button>
|
||||
<button className="primary" onClick={apply} disabled={busy || missingInv}>
|
||||
{busy ? '…' : `Importer ${preview.allRowCount} lignes`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Aperçu (10 premières lignes)</h3>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>{preview.headers.map(h => <th key={h}>{h}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.sampleRows.map((r, i) => (
|
||||
<tr key={i}>{preview.headers.map(h => <td key={h}>{String(r[h] ?? '')}</td>)}</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Import Dossier Investissement ──────────────────────── */}
|
||||
<DossierImport
|
||||
activeId={activeId}
|
||||
navigate={navigate}
|
||||
dossierFile={dossierFile}
|
||||
setDossierFile={setDossierFile}
|
||||
dossierPreview={dossierPreview}
|
||||
setDossierPreview={setDossierPreview}
|
||||
dossierResult={dossierResult}
|
||||
setDossierResult={setDossierResult}
|
||||
dossierBusy={dossierBusy}
|
||||
setDossierBusy={setDossierBusy}
|
||||
dossierErr={dossierErr}
|
||||
setDossierErr={setDossierErr}
|
||||
dossierInputRef={dossierInputRef}
|
||||
reloadHistory={() => api.get('/imports/history').then(setHistory).catch(() => {})}
|
||||
/>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Historique des imports</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Module</th>
|
||||
<th>Fichier</th>
|
||||
<th className="num">Total</th>
|
||||
<th className="num">OK</th>
|
||||
<th className="num">KO</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-muted" style={{ textAlign: 'center' }}>Aucun import</td></tr>
|
||||
)}
|
||||
{history.map(h => (
|
||||
<tr key={h.id}>
|
||||
<td>{fmtDate(h.created_at)}</td>
|
||||
<td>{MODULE_LABEL[h.module] ?? h.module}</td>
|
||||
<td className="text-muted" style={{ fontSize: 11 }}>{h.filename}</td>
|
||||
<td className="num">{h.rows_total}</td>
|
||||
<td className="num" style={{ color: 'var(--success)' }}>{h.rows_inserted}</td>
|
||||
<td className="num" style={{ color: h.rows_skipped > 0 ? 'var(--warning)' : undefined }}>
|
||||
{h.rows_skipped}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Composant import dossier ──────────────────────────────────── */
|
||||
function DossierImport({
|
||||
activeId, navigate,
|
||||
dossierFile, setDossierFile,
|
||||
dossierPreview, setDossierPreview,
|
||||
dossierResult, setDossierResult,
|
||||
dossierBusy, setDossierBusy,
|
||||
dossierErr, setDossierErr,
|
||||
dossierInputRef, reloadHistory,
|
||||
}) {
|
||||
const missingInv = !activeId;
|
||||
|
||||
const onFileChange = (e) => {
|
||||
const f = e.target.files[0];
|
||||
setDossierFile(f || null);
|
||||
setDossierPreview(null);
|
||||
setDossierResult(null);
|
||||
setDossierErr(null);
|
||||
if (!f) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
try {
|
||||
const parsed = JSON.parse(ev.target.result);
|
||||
if (parsed.type !== 'dossier_investissement') {
|
||||
setDossierErr('Ce fichier n\'est pas un dossier investissement valide (type incorrect).');
|
||||
return;
|
||||
}
|
||||
setDossierPreview(parsed);
|
||||
} catch {
|
||||
setDossierErr('Fichier JSON invalide — vérifiez la syntaxe.');
|
||||
}
|
||||
};
|
||||
reader.readAsText(f);
|
||||
};
|
||||
|
||||
const onImport = async () => {
|
||||
if (!dossierPreview) return;
|
||||
setDossierBusy(true); setDossierErr(null); setDossierResult(null);
|
||||
try {
|
||||
const r = await api.post('/imports/dossier', { dossier: dossierPreview });
|
||||
setDossierResult(r);
|
||||
setDossierFile(null); setDossierPreview(null);
|
||||
if (dossierInputRef.current) dossierInputRef.current.value = '';
|
||||
reloadHistory();
|
||||
} catch (e) { setDossierErr(e.message); }
|
||||
finally { setDossierBusy(false); }
|
||||
};
|
||||
|
||||
const dp = dossierPreview;
|
||||
const inv = dp?.investissement;
|
||||
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Import — Dossier investissement</h3>
|
||||
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', marginBottom: 12 }}>
|
||||
Restaure ou migre un dossier complet (investissement + remboursements + historique) depuis un fichier
|
||||
<code style={{ margin: '0 4px' }}>.json</code> exporté par cette application.
|
||||
Si le dossier existe déjà, il sera mis à jour ; sinon il sera créé.
|
||||
</p>
|
||||
|
||||
{missingInv && (
|
||||
<div className="error" style={{ marginBottom: 10 }}>
|
||||
Sélectionnez un investisseur actif avant d'importer un dossier.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="row" style={{ gap: 10, alignItems: 'flex-end' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label>Fichier dossier <code>.json</code></label>
|
||||
<input
|
||||
ref={dossierInputRef}
|
||||
type="file" accept=".json"
|
||||
disabled={missingInv}
|
||||
onChange={onFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dossierErr && <div className="error" style={{ marginTop: 10 }}>{dossierErr}</div>}
|
||||
|
||||
{/* Aperçu du dossier avant import */}
|
||||
{dp && inv && (
|
||||
<div style={{ marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 14 }}>
|
||||
<h4 style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)' }}>Aperçu du dossier</h4>
|
||||
<table style={{ marginBottom: 0 }}>
|
||||
<tbody>
|
||||
<tr><td style={{ width: 200 }}>Projet</td><td><strong>{inv.nom_projet}</strong></td></tr>
|
||||
<tr><td>Plateforme</td><td>{dp.plateforme?.nom}</td></tr>
|
||||
<tr><td>Date souscription</td><td>{fmtDate(inv.date_souscription)}</td></tr>
|
||||
<tr><td>Montant investi</td><td>{inv.montant_investi} €</td></tr>
|
||||
<tr><td>Statut</td><td>{inv.statut}</td></tr>
|
||||
<tr><td>Remboursements</td><td>{dp.remboursements?.length ?? 0} enregistrement(s)</td></tr>
|
||||
<tr><td>Projections</td><td>{dp.projections?.length ?? 0} échéance(s)</td></tr>
|
||||
<tr><td>Historique</td><td>{dp.historique?.length ?? 0} entrée(s)</td></tr>
|
||||
<tr><td>Exporté le</td><td className="text-muted" style={{ fontSize: 11 }}>{dp.exported_at}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => { setDossierFile(null); setDossierPreview(null); if (dossierInputRef.current) dossierInputRef.current.value = ''; }}>
|
||||
Annuler
|
||||
</button>
|
||||
<button className="primary" onClick={onImport} disabled={dossierBusy || missingInv}>
|
||||
{dossierBusy ? '…' : 'Importer ce dossier'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dossierResult && (
|
||||
<div className="success-msg" style={{ marginTop: 12 }}>
|
||||
{dossierResult.action === 'created'
|
||||
? '✔ Dossier créé avec succès.'
|
||||
: '✔ Dossier mis à jour avec succès.'
|
||||
}
|
||||
{' '}
|
||||
<button
|
||||
style={{ marginLeft: 8, fontSize: 'var(--fs-xs)', padding: '2px 8px' }}
|
||||
onClick={() => navigate(`/investissements/${dossierResult.investissementId}`)}
|
||||
>
|
||||
Ouvrir le dossier →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext.jsx';
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [err, setErr] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setErr(null); setBusy(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/');
|
||||
} catch (e) {
|
||||
setErr(e.message);
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<form className="card login-card" onSubmit={submit}>
|
||||
<h2 style={{ marginTop: 0 }}>Connexion</h2>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<label>Email</label>
|
||||
<input type="email" required value={email} onChange={e => setEmail(e.target.value)} />
|
||||
<div style={{ height: 10 }} />
|
||||
<label>Mot de passe</label>
|
||||
<input type="password" required value={password} onChange={e => setPassword(e.target.value)} />
|
||||
<div style={{ height: 16 }} />
|
||||
<button className="primary" type="submit" disabled={busy} style={{ width: '100%' }}>
|
||||
{busy ? '…' : 'Se connecter'}
|
||||
</button>
|
||||
<p className="text-muted" style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
Pas encore de compte ? <Link to="/register">Créer un compte</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext.jsx';
|
||||
import { useUi } from '../context/UiContext.jsx';
|
||||
import { api } from '../api.js';
|
||||
|
||||
/* ── Icônes nav ─────────────────────────────────────────────── */
|
||||
function IconUser() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>;
|
||||
}
|
||||
function IconLock() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>;
|
||||
}
|
||||
|
||||
/* ── Dropdown custom style Finary ────────────────────────────── */
|
||||
const LANGUES = [
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'en', label: 'English' },
|
||||
];
|
||||
const DEVISES = [
|
||||
{ value: 'EUR', label: '€ - EUR' },
|
||||
{ value: 'USD', label: '$ - USD' },
|
||||
{ value: 'GBP', label: '£ - GBP' },
|
||||
{ value: 'CHF', label: 'CHF' },
|
||||
{ value: 'CAD', label: 'CA$ - CAD' },
|
||||
{ value: 'SGD', label: 'SGD' },
|
||||
];
|
||||
|
||||
function ProfileSelect({ label, options, value, onChange }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const h = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
|
||||
document.addEventListener('mousedown', h);
|
||||
return () => document.removeEventListener('mousedown', h);
|
||||
}, [open]);
|
||||
|
||||
const selected = options.find(o => o.value === value);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="profile-field">
|
||||
<span className="profile-label">{label}</span>
|
||||
<div className={`profile-select-trigger${open ? ' open' : ''}`}
|
||||
onClick={() => setOpen(o => !o)} role="button" tabIndex={0}
|
||||
onKeyDown={e => e.key === 'Enter' && setOpen(o => !o)}>
|
||||
<span>{selected?.label}</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ transition: 'transform .15s', transform: open ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||
aria-hidden="true">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="profile-select-dropdown" role="listbox">
|
||||
{options.map(o => (
|
||||
<div key={o.value}
|
||||
className={`profile-select-option${o.value === value ? ' selected' : ''}`}
|
||||
role="option" aria-selected={o.value === value}
|
||||
onClick={() => { onChange(o.value); setOpen(false); }}>
|
||||
{o.label}
|
||||
{o.value === value && (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
aria-hidden="true">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Mon profil + Préférences ────────────────────────────────── */
|
||||
function AccountForm() {
|
||||
const { user, updateUser } = useAuth();
|
||||
const { langue, setLangue, devise, setDevise } = useUi();
|
||||
|
||||
/* Découpe display_name en prénom / nom */
|
||||
const parseName = (dn = '') => {
|
||||
const parts = dn.trim().split(' ');
|
||||
return parts.length >= 2
|
||||
? { prenom: parts[0], nom: parts.slice(1).join(' ') }
|
||||
: { prenom: dn.trim(), nom: '' };
|
||||
};
|
||||
|
||||
const initial = parseName(user?.display_name);
|
||||
const [prenom, setPrenom] = useState(initial.prenom);
|
||||
const [nom, setNom] = useState(initial.nom);
|
||||
const [infoMsg, setInfoMsg] = useState(null);
|
||||
const [infoErr, setInfoErr] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const save = async () => {
|
||||
setInfoErr(null); setInfoMsg(null); setLoading(true);
|
||||
try {
|
||||
const displayName = [prenom.trim(), nom.trim()].filter(Boolean).join(' ');
|
||||
await updateUser({ displayName });
|
||||
setInfoMsg('Profil mis à jour.');
|
||||
} catch (err) { setInfoErr(err.message); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
/* Sauvegarde auto à la perte du focus */
|
||||
const handleBlur = () => save();
|
||||
|
||||
return (
|
||||
<div className="profile-page">
|
||||
|
||||
{/* ── Mon profil ──────────────────────────────────────── */}
|
||||
<section className="profile-section">
|
||||
<h2 className="profile-section-title">Mon profil</h2>
|
||||
|
||||
{infoErr && <div className="error" style={{ marginBottom: 12 }}>{infoErr}</div>}
|
||||
{infoMsg && <div className="success-msg" style={{ marginBottom: 12 }}>{infoMsg}</div>}
|
||||
|
||||
<div className="profile-grid-2">
|
||||
<div className="profile-field">
|
||||
<span className="profile-label">Prénom</span>
|
||||
<input className="profile-input" value={prenom}
|
||||
onChange={e => setPrenom(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
placeholder="Prénom" />
|
||||
</div>
|
||||
<div className="profile-field">
|
||||
<span className="profile-label">Nom</span>
|
||||
<input className="profile-input" value={nom}
|
||||
onChange={e => setNom(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
placeholder="NOM" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-field profile-field-full">
|
||||
<span className="profile-label">Mon email</span>
|
||||
<div className="profile-email-row">
|
||||
<span className="profile-email-value">{user?.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<button className="profile-manage-btn" type="button" disabled>
|
||||
Gérer mon email
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Préférences ─────────────────────────────────────── */}
|
||||
<section className="profile-section">
|
||||
<h2 className="profile-section-title">Préférences</h2>
|
||||
<div className="profile-grid-2">
|
||||
<ProfileSelect label="Langue" options={LANGUES} value={langue} onChange={setLangue} />
|
||||
<ProfileSelect label="Devise" options={DEVISES} value={devise} onChange={setDevise} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{loading && <p className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>Enregistrement…</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Sécurité — Mot de passe ─────────────────────────────────── */
|
||||
function SecurityForm() {
|
||||
const { updateUser } = useAuth();
|
||||
|
||||
const [pwdForm, setPwdForm] = useState({ currentPassword: '', newPassword: '', confirm: '' });
|
||||
const [pwdMsg, setPwdMsg] = useState(null);
|
||||
const [pwdErr, setPwdErr] = useState(null);
|
||||
const [pwdLoading, setPwdLoading] = useState(false);
|
||||
|
||||
const savePwd = async (e) => {
|
||||
e.preventDefault(); setPwdErr(null); setPwdMsg(null);
|
||||
if (pwdForm.newPassword !== pwdForm.confirm) { setPwdErr('Les mots de passe ne correspondent pas.'); return; }
|
||||
if (pwdForm.newPassword.length < 8) { setPwdErr('8 caractères minimum.'); return; }
|
||||
setPwdLoading(true);
|
||||
try {
|
||||
await updateUser({ currentPassword: pwdForm.currentPassword, newPassword: pwdForm.newPassword });
|
||||
setPwdMsg('Mot de passe modifié avec succès.');
|
||||
setPwdForm({ currentPassword: '', newPassword: '', confirm: '' });
|
||||
} catch (err) { setPwdErr(err.message); }
|
||||
finally { setPwdLoading(false); }
|
||||
};
|
||||
|
||||
const handleBackfillComptes = async () => {
|
||||
setLoadingBackfill(true);
|
||||
setErrorMsg(null);
|
||||
setSuccessMsg(null);
|
||||
try {
|
||||
const { updated, total } = await api.post('/remboursements/backfill-comptes', {});
|
||||
if (updated === 0) {
|
||||
setSuccessMsg(`Aucun remboursement à corriger (${total} vérifié${total > 1 ? 's' : ''}).`);
|
||||
} else {
|
||||
setSuccessMsg(`${updated} remboursement${updated > 1 ? 's' : ''} mis à jour sur ${total} vérifié${total > 1 ? 's' : ''}.`);
|
||||
}
|
||||
setShowBackfillModal(false);
|
||||
} catch (err) {
|
||||
setErrorMsg(err.message || 'Une erreur est survenue.');
|
||||
setShowBackfillModal(false);
|
||||
} finally {
|
||||
setLoadingBackfill(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 4px' }}>Mot de passe</h3>
|
||||
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
|
||||
Saisissez votre mot de passe actuel puis choisissez-en un nouveau (8 caractères minimum).
|
||||
</p>
|
||||
{pwdErr && <div className="error">{pwdErr}</div>}
|
||||
{pwdMsg && <div className="success-msg">{pwdMsg}</div>}
|
||||
<form onSubmit={savePwd}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 400 }}>
|
||||
<div>
|
||||
<label>Mot de passe actuel</label>
|
||||
<input type="password" required autoComplete="current-password"
|
||||
value={pwdForm.currentPassword}
|
||||
onChange={e => setPwdForm({ ...pwdForm, currentPassword: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Nouveau mot de passe</label>
|
||||
<input type="password" required autoComplete="new-password"
|
||||
value={pwdForm.newPassword}
|
||||
onChange={e => setPwdForm({ ...pwdForm, newPassword: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Confirmer le nouveau mot de passe</label>
|
||||
<input type="password" required autoComplete="new-password"
|
||||
value={pwdForm.confirm}
|
||||
onChange={e => setPwdForm({ ...pwdForm, confirm: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
|
||||
<button className="primary" type="submit" disabled={pwdLoading}>
|
||||
{pwdLoading ? 'Modification…' : 'Modifier le mot de passe'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Page principale ─────────────────────────────────────────── */
|
||||
export default function MonCompte() {
|
||||
const { search } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const section = new URLSearchParams(search).get('section') || 'profil';
|
||||
const setSection = (s) => navigate(`/compte?section=${s}`, { replace: true });
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: 'profil', label: 'Mon compte', icon: <IconUser /> },
|
||||
{ id: 'securite', label: 'Sécurité', icon: <IconLock /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="account-layout">
|
||||
|
||||
{/* ── Nav gauche ───────────────────────────────────────── */}
|
||||
<aside className="account-sidebar">
|
||||
<h1 className="account-title">Mon compte</h1>
|
||||
{SECTIONS.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`account-nav-item${section === item.id ? ' active' : ''}`}
|
||||
onClick={() => setSection(item.id)}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
{/* ── Contenu ─────────────────────────────────────── */}
|
||||
<div className="account-content">
|
||||
{section === 'profil' && <AccountForm />}
|
||||
{section === 'securite' && <SecurityForm />}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,152 @@
|
||||
import { useTheme } from '../context/ThemeContext.jsx';
|
||||
import { useUi } from '../context/UiContext.jsx';
|
||||
|
||||
/* ── Theme options ─────────────────────────────────────────── */
|
||||
const THEMES = [
|
||||
{
|
||||
mode: 'light',
|
||||
label: 'Clair',
|
||||
desc: 'Interface lumineuse',
|
||||
preview: (
|
||||
<svg viewBox="0 0 56 36" width="56" height="36" aria-hidden="true">
|
||||
<rect width="56" height="36" rx="5" fill="#f0f4ff"/>
|
||||
<rect x="2" y="2" width="12" height="32" rx="3" fill="#1e3a8a"/>
|
||||
<rect x="16" y="2" width="38" height="8" rx="2" fill="#ffffff" opacity=".9"/>
|
||||
<rect x="16" y="12" width="38" height="5" rx="2" fill="#c7d2e8"/>
|
||||
<rect x="16" y="19" width="28" height="5" rx="2" fill="#c7d2e8"/>
|
||||
<rect x="16" y="26" width="20" height="5" rx="2" fill="#c7d2e8"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
mode: 'dark',
|
||||
label: 'Sombre',
|
||||
desc: 'Interface nocturne',
|
||||
preview: (
|
||||
<svg viewBox="0 0 56 36" width="56" height="36" aria-hidden="true">
|
||||
<rect width="56" height="36" rx="5" fill="#060e1f"/>
|
||||
<rect x="2" y="2" width="12" height="32" rx="3" fill="#0d1629"/>
|
||||
<rect x="16" y="2" width="38" height="8" rx="2" fill="#111c35" opacity=".9"/>
|
||||
<rect x="16" y="12" width="38" height="5" rx="2" fill="#1e3a6a"/>
|
||||
<rect x="16" y="19" width="28" height="5" rx="2" fill="#1e3a6a"/>
|
||||
<rect x="16" y="26" width="20" height="5" rx="2" fill="#1e3a6a"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
mode: 'system',
|
||||
label: 'Système',
|
||||
desc: 'Suit les préférences OS',
|
||||
preview: (
|
||||
<svg viewBox="0 0 56 36" width="56" height="36" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="split" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="50%" stopColor="#f0f4ff"/>
|
||||
<stop offset="50%" stopColor="#060e1f"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="56" height="36" rx="5" fill="url(#split)"/>
|
||||
<rect x="2" y="2" width="12" height="32" rx="3" fill="#1e3a8a"/>
|
||||
<rect x="16" y="2" width="18" height="8" rx="2" fill="#ffffff" opacity=".9"/>
|
||||
<rect x="36" y="2" width="18" height="8" rx="2" fill="#111c35" opacity=".9"/>
|
||||
<rect x="16" y="12" width="18" height="5" rx="2" fill="#c7d2e8"/>
|
||||
<rect x="36" y="12" width="18" height="5" rx="2" fill="#1e3a6a"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/* ── Font scale options ────────────────────────────────────── */
|
||||
const FONTS = [
|
||||
{
|
||||
scale: 'compact',
|
||||
label: 'Normal',
|
||||
desc: 'Interface compacte',
|
||||
sizes: { body: 12, table: 11 },
|
||||
},
|
||||
{
|
||||
scale: 'medium',
|
||||
label: 'Moyen',
|
||||
desc: 'Taille intermédiaire',
|
||||
sizes: { body: 13, table: 12 },
|
||||
},
|
||||
{
|
||||
scale: 'large',
|
||||
label: 'Grand',
|
||||
desc: 'Meilleure lisibilité',
|
||||
sizes: { body: 14, table: 13 },
|
||||
},
|
||||
];
|
||||
|
||||
/* ── Component ─────────────────────────────────────────────── */
|
||||
export default function Preferences() {
|
||||
const { mode, setMode } = useTheme();
|
||||
const { fontScale, setFontScale } = useUi();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<h2>Interface</h2>
|
||||
</div>
|
||||
|
||||
{/* ── Thème ──────────────────────────────────────────── */}
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 4px' }}>Apparence</h3>
|
||||
<p style={{ margin: '0 0 16px', color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>
|
||||
Choisissez le thème visuel de l'application.
|
||||
</p>
|
||||
<div className="pref-options">
|
||||
{THEMES.map((t) => (
|
||||
<button
|
||||
key={t.mode}
|
||||
type="button"
|
||||
className={`pref-option${mode === t.mode ? ' active' : ''}`}
|
||||
onClick={() => setMode(t.mode)}
|
||||
aria-pressed={mode === t.mode}
|
||||
>
|
||||
{t.preview}
|
||||
<span style={{ fontWeight: 700, fontSize: 'var(--fs-sm)', marginTop: 4 }}>{t.label}</span>
|
||||
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>{t.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Police ─────────────────────────────────────────── */}
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 4px' }}>Taille du texte</h3>
|
||||
<p style={{ margin: '0 0 16px', color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>
|
||||
Le niveau <strong>Grand</strong> est recommandé pour les personnes malvoyantes.
|
||||
Les niveaux inférieurs permettent d'afficher plus de données à l'écran.
|
||||
</p>
|
||||
<div className="pref-options">
|
||||
{FONTS.map((f) => (
|
||||
<button
|
||||
key={f.scale}
|
||||
type="button"
|
||||
className={`pref-option${fontScale === f.scale ? ' active' : ''}`}
|
||||
onClick={() => setFontScale(f.scale)}
|
||||
aria-pressed={fontScale === f.scale}
|
||||
>
|
||||
{/* Live-size text preview */}
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4,
|
||||
width: '100%', padding: '8px 0 4px',
|
||||
borderBottom: '1px solid var(--border)', marginBottom: 4,
|
||||
}}>
|
||||
<span className="font-preview" style={{ fontSize: f.sizes.body }}>
|
||||
Aa — {f.label}
|
||||
</span>
|
||||
<span style={{ fontSize: f.sizes.table, color: 'var(--text-muted)' }}>
|
||||
Tableau {f.sizes.table}px · Corps {f.sizes.body}px
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontWeight: 700, fontSize: 'var(--fs-sm)', marginTop: 2 }}>{f.label}</span>
|
||||
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>{f.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext.jsx';
|
||||
|
||||
export default function Register() {
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [form, setForm] = useState({ email: '', password: '', displayName: '' });
|
||||
const [err, setErr] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const set = (k) => (e) => setForm({ ...form, [k]: e.target.value });
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setErr(null); setBusy(true);
|
||||
try {
|
||||
await register(form.email, form.password, form.displayName || undefined);
|
||||
navigate('/');
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<form className="card login-card" onSubmit={submit}>
|
||||
<h2 style={{ marginTop: 0 }}>Créer un compte</h2>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<label>Nom d'affichage</label>
|
||||
<input value={form.displayName} onChange={set('displayName')} placeholder="Olivier" />
|
||||
<div style={{ height: 10 }} />
|
||||
<label>Email</label>
|
||||
<input type="email" required value={form.email} onChange={set('email')} />
|
||||
<div style={{ height: 10 }} />
|
||||
<label>Mot de passe (8 car. min.)</label>
|
||||
<input type="password" required minLength={8} value={form.password} onChange={set('password')} />
|
||||
<div style={{ height: 16 }} />
|
||||
<button className="primary" type="submit" disabled={busy} style={{ width: '100%' }}>
|
||||
{busy ? '…' : 'Créer le compte'}
|
||||
</button>
|
||||
<p className="text-muted" style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
Déjà inscrit ? <Link to="/login">Se connecter</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import FamilleEntreprises from './FamilleEntreprises.jsx';
|
||||
import AppearanceSection from './settings/AppearanceSection.jsx';
|
||||
import PlateformesSection from './settings/PlateformesSection.jsx';
|
||||
import CategoriesInvSection from './settings/CategoriesInvSection.jsx';
|
||||
import SecteursInvSection from './settings/SecteursInvSection.jsx';
|
||||
import ComptesSection from './settings/ComptesSection.jsx';
|
||||
import MaFiscaliteSection from './settings/MaFiscaliteSection.jsx';
|
||||
import DataCleanupSection from './settings/DataCleanupSection.jsx';
|
||||
import ImportsSection from './settings/ImportsSection.jsx';
|
||||
|
||||
/* ── Icônes nav ───────────────────────────────────────────────── */
|
||||
function IconFamily() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>; }
|
||||
function IconMonitor() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>; }
|
||||
function IconServer() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>; }
|
||||
function IconWallet() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M16 12h.01M2 10h20"/></svg>; }
|
||||
function IconTag() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>; }
|
||||
function IconLayers() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>; }
|
||||
function IconGrid() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>; }
|
||||
function IconMyFiscal() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M20 7H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/><path d="M16 3H8l-2 4h12l-2-4z"/><line x1="12" y1="12" x2="12" y2="12.01"/></svg>; }
|
||||
function IconBroom() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 21l9-9"/><path d="M12.22 6.22L17 1.5l5.5 5.5-4.72 4.78"/><path d="M5 17c.5-2 2-3.5 4-4.5l3.5 3.5c-1 2-2.5 3.5-4.5 4"/></svg>; }
|
||||
function IconUpload() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>; }
|
||||
|
||||
const NAV = [
|
||||
{
|
||||
group: 'Interface',
|
||||
items: [
|
||||
{ id: 'apparence', label: 'Apparence', icon: <IconMonitor /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Mon paramétrage',
|
||||
items: [
|
||||
{ id: 'membres', label: 'Mes membres & entreprises', icon: <IconFamily /> },
|
||||
{ id: 'plateformes', label: 'Mes plateformes', icon: <IconServer /> },
|
||||
{ id: 'comptes', label: 'Mes comptes courants', icon: <IconWallet /> },
|
||||
{ id: 'ma-fiscalite', label: 'Ma fiscalité', icon: <IconMyFiscal /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Mes tags',
|
||||
items: [
|
||||
{ id: 'categories-inv', label: "Mes catégories d'investissement", icon: <IconLayers /> },
|
||||
{ id: 'secteurs-inv', label: "Mes secteurs d'investissement", icon: <IconGrid /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Mes données',
|
||||
items: [
|
||||
{ id: 'nettoyage', label: 'Nettoyage de données', icon: <IconBroom /> },
|
||||
{ id: 'imports', label: 'Importation de données', icon: <IconUpload /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function Settings() {
|
||||
const { search } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const section = new URLSearchParams(search).get('section') || 'apparence';
|
||||
const setSection = (s) => navigate(`/settings?section=${s}`, { replace: true });
|
||||
|
||||
return (
|
||||
<div className="account-layout">
|
||||
<aside className="account-sidebar">
|
||||
<h1 className="account-title">Gérer les paramètres</h1>
|
||||
{NAV.map(group => (
|
||||
<div key={group.group} className="account-nav-group">
|
||||
<span className="account-nav-label">{group.group}</span>
|
||||
{group.items.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`account-nav-item${section === item.id ? ' active' : ''}`}
|
||||
onClick={() => setSection(item.id)}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<div className="account-content">
|
||||
{section === 'apparence' && <AppearanceSection />}
|
||||
{section === 'membres' && <FamilleEntreprises />}
|
||||
{section === 'plateformes' && <PlateformesSection />}
|
||||
{section === 'comptes' && <ComptesSection />}
|
||||
{section === 'ma-fiscalite' && <MaFiscaliteSection />}
|
||||
{section === 'categories-inv' && <CategoriesInvSection />}
|
||||
{section === 'secteurs-inv' && <SecteursInvSection />}
|
||||
{section === 'nettoyage' && <DataCleanupSection />}
|
||||
{section === 'imports' && <ImportsSection />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
import { fmtEUR, fmtDate } from '../utils/format.js';
|
||||
|
||||
export default function SimulRemboursements() {
|
||||
const { activeId } = useInvestisseur();
|
||||
const [investissements, setInvestissements] = useState([]);
|
||||
const [selected, setSelected] = useState('');
|
||||
const [echeances, setEcheances] = useState([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeId) return;
|
||||
api.get('/investissements').then(setInvestissements);
|
||||
}, [activeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) { setEcheances([]); return; }
|
||||
api.get('/simul', { investissement_id: selected }).then(setEcheances);
|
||||
}, [selected]);
|
||||
|
||||
const generate = async () => {
|
||||
if (!selected) return;
|
||||
setBusy(true); setMsg(null);
|
||||
try {
|
||||
const r = await api.post('/simul/generate', {
|
||||
investissement_id: Number(selected),
|
||||
replace: true,
|
||||
});
|
||||
setMsg(`✔ ${r.inserted} échéances générées.`);
|
||||
const e = await api.get('/simul', { investissement_id: selected });
|
||||
setEcheances(e);
|
||||
} catch (e) {
|
||||
setMsg(`✗ ${e.message}`);
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const inv = investissements.find(i => i.id === Number(selected));
|
||||
const totals = echeances.reduce((acc, e) => {
|
||||
acc.capital += e.capital_prevu;
|
||||
acc.interets += e.interets_prevus;
|
||||
acc.total += e.total_prevu;
|
||||
return acc;
|
||||
}, { capital: 0, interets: 0, total: 0 });
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar"><h2>Projections Remboursements</h2></div>
|
||||
|
||||
<div className="card">
|
||||
<div className="row">
|
||||
<div style={{ flex: 2 }}>
|
||||
<label>Investissement</label>
|
||||
<select value={selected} onChange={e => setSelected(e.target.value)}>
|
||||
<option value="">— Choisir —</option>
|
||||
{investissements.map(i =>
|
||||
<option key={i.id} value={i.id}>
|
||||
{i.nom_projet} ({fmtEUR(i.montant_investi)} – {i.taux_interet ?? '?'}% – {i.duree_mois ?? '?'}m – {i.type_remb || '?'})
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<button className="primary" onClick={generate} disabled={!selected || busy} style={{ width: '100%' }}>
|
||||
{busy ? '…' : 'Générer / Régénérer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{msg && <div className={msg.startsWith('✔') ? 'success-msg' : 'error'} style={{ marginTop: 12 }}>{msg}</div>}
|
||||
{inv && (!inv.taux_interet || !inv.duree_mois) && (
|
||||
<div className="error" style={{ marginTop: 12 }}>
|
||||
Cet investissement n'a pas de <strong>taux</strong> et/ou de <strong>durée</strong>. Renseignez-les dans la fiche.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{echeances.length > 0 && (
|
||||
<div className="card">
|
||||
<div className="kpi-grid" style={{ marginBottom: 12 }}>
|
||||
<div className="kpi"><div className="label">Capital prévu</div><div className="value">{fmtEUR(totals.capital)}</div></div>
|
||||
<div className="kpi"><div className="label">Intérêts prévus</div><div className="value success">{fmtEUR(totals.interets)}</div></div>
|
||||
<div className="kpi"><div className="label">Total prévu</div><div className="value">{fmtEUR(totals.total)}</div></div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th><th>Date prévue</th>
|
||||
<th className="num">Capital</th><th className="num">Intérêts</th>
|
||||
<th className="num">Total échéance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{echeances.map(e => (
|
||||
<tr key={e.id}>
|
||||
<td>{e.numero_echeance}</td>
|
||||
<td>{fmtDate(e.date_prevue)}</td>
|
||||
<td className="num">{fmtEUR(e.capital_prevu)}</td>
|
||||
<td className="num">{fmtEUR(e.interets_prevus)}</td>
|
||||
<td className="num">{fmtEUR(e.total_prevu)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import PageIcon from '../components/PageIcon.jsx';
|
||||
import Pagination from '../components/Pagination.jsx';
|
||||
import { usePagination } from '../hooks/usePagination.js';
|
||||
import { api } from '../api.js';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
import { useUi } from '../context/UiContext.jsx';
|
||||
import { fmtEUR, fmtStatut } from '../utils/format.js';
|
||||
import Cerfa2561Preview from '../components/Cerfa2561Preview.jsx';
|
||||
import CerfaRecapTable from '../components/CerfaRecapTable.jsx';
|
||||
import Cerfa2778Preview from '../components/Cerfa2778Preview.jsx';
|
||||
import Cerfa2042Preview from '../components/Cerfa2042Preview.jsx';
|
||||
|
||||
/* ── YearSelector ────────────────────────────────────────────── */
|
||||
function YearSelector({ annee, setAnnee, availableYears }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
|
||||
document.addEventListener('mousedown', h);
|
||||
return () => document.removeEventListener('mousedown', h);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', flexShrink: 0, width: 160 }}>
|
||||
<div
|
||||
onClick={() => setOpen(v => !v)}
|
||||
style={{
|
||||
height: '100%', boxSizing: 'border-box',
|
||||
background: 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)',
|
||||
borderRadius: 10,
|
||||
padding: '12px 16px',
|
||||
boxShadow: open
|
||||
? '0 6px 28px rgba(109,40,217,0.45)'
|
||||
: '0 4px 20px rgba(109,40,217,0.30)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
|
||||
userSelect: 'none',
|
||||
transition: 'box-shadow .15s',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{
|
||||
fontSize: 'var(--fs-xs)', textTransform: 'uppercase',
|
||||
letterSpacing: '.06em', color: 'rgba(255,255,255,0.7)', fontWeight: 500,
|
||||
}}>Année</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="rgba(255,255,255,0.7)" strokeWidth="2.5"
|
||||
strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ color: '#fff', fontSize: '1.8rem', fontWeight: 700, lineHeight: 1.1, marginTop: 6 }}>
|
||||
{annee}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 200,
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 10, boxShadow: '0 8px 28px rgba(0,0,0,0.15)',
|
||||
minWidth: 160, overflow: 'hidden',
|
||||
}}>
|
||||
{availableYears.map((yr, i) => {
|
||||
const isActive = String(yr) === String(annee);
|
||||
return (
|
||||
<div
|
||||
key={yr}
|
||||
onClick={() => { setAnnee(String(yr)); setOpen(false); }}
|
||||
style={{
|
||||
padding: '10px 16px', cursor: 'pointer',
|
||||
background: isActive ? 'rgba(109,40,217,0.08)' : 'transparent',
|
||||
color: isActive ? '#7c3aed' : 'var(--text)',
|
||||
fontWeight: isActive ? 700 : 400,
|
||||
fontSize: 'var(--fs-sm)',
|
||||
borderBottom: i < availableYears.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
transition: 'background .1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--surface-2)'; }}
|
||||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<span>{yr}</span>
|
||||
{isActive && (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="#7c3aed" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── ExportDropdown ──────────────────────────────────────────── */
|
||||
function ExportDropdown({ disabled, onCSV, onJSON }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
|
||||
document.addEventListener('mousedown', h);
|
||||
return () => document.removeEventListener('mousedown', h);
|
||||
}, [open]);
|
||||
const choose = fn => { setOpen(false); fn(); };
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative' }}>
|
||||
<button type="button" className="icon-btn" disabled={disabled}
|
||||
onClick={() => setOpen(o => !o)} title="Exporter">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="export-dropdown" role="menu">
|
||||
<button role="menuitem" onClick={() => choose(onCSV)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/>
|
||||
</svg>
|
||||
<span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
|
||||
</button>
|
||||
<button role="menuitem" onClick={() => choose(onJSON)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
|
||||
<path d="M8 13h1.5a1 1 0 0 1 1 1v1a1 1 0 0 0 1 1 1 1 0 0 0-1 1v1a1 1 0 0 1-1 1H8"/>
|
||||
<path d="M16 13h-1.5a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1H16"/>
|
||||
</svg>
|
||||
<span><strong>Format JSON</strong><small>Réimportable, structuré</small></span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Composant principal ─────────────────────────────────────── */
|
||||
export default function TaxReport() {
|
||||
const { activeId, activeView } = useInvestisseur();
|
||||
const { pfoAssujetti } = useUi();
|
||||
const [annee, setAnnee] = useState(String(new Date().getFullYear()));
|
||||
const [availableYears, setAvailableYears] = useState([]);
|
||||
const [data, setData] = useState(null);
|
||||
const [data2042, setData2042] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('2042');
|
||||
const [detailExpanded, setDetailExpanded] = useState(false);
|
||||
const [cerfaExpanded, setCerfaExpanded] = useState(false);
|
||||
|
||||
/* ── Chargement des années disponibles ── */
|
||||
useEffect(() => {
|
||||
if (!activeId && activeView !== 'all') return;
|
||||
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||
api.get('/taxreport/years', scopeParams).then(years => {
|
||||
setAvailableYears(years);
|
||||
// Si l'année courante n'est pas dans la liste, prendre la première disponible
|
||||
if (years.length > 0 && !years.map(String).includes(annee)) {
|
||||
setAnnee(String(years[0]));
|
||||
}
|
||||
});
|
||||
}, [activeId, activeView]); // eslint-disable-line
|
||||
|
||||
const load = () => {
|
||||
if (!activeId && activeView !== 'all') return;
|
||||
setData(null);
|
||||
setLoading(true);
|
||||
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||
const LS_EXCL = 'cl_2778_excluded_plats';
|
||||
const excluded = new Set(JSON.parse(localStorage.getItem(LS_EXCL) ?? '[]'));
|
||||
Promise.all([
|
||||
api.get('/taxreport', { annee, ...scopeParams }),
|
||||
api.get('/taxreport/cerfa2561', { annee, ...scopeParams }),
|
||||
api.get('/taxreport/2778', { annee, ...scopeParams }),
|
||||
]).then(([d, d2561, d2778]) => {
|
||||
setData(d);
|
||||
const frLignes = (d2561?.lignes ?? []).filter(l => l.domiciliation === 'FR');
|
||||
const platEtr = (d2778?.plateformes ?? []).filter(p => !excluded.has(p.id));
|
||||
const etrBA = p => Object.values(p.mois ?? {}).reduce((s, v) => s + v, 0);
|
||||
const pfo = 0.128; // taux par défaut — affiné par pfuList si dispo
|
||||
setData2042({
|
||||
case_2TT: frLignes.reduce((s, l) => s + (l.case_2TT ?? 0), 0),
|
||||
case_2TR: frLignes.reduce((s, l) => s + (l.case_2TR ?? 0), 0) + Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0)),
|
||||
case_2BH: frLignes.reduce((s, l) => s + (l.case_2BH ?? 0), 0) + Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0)),
|
||||
case_2CK: frLignes.reduce((s, l) => s + (l.case_2CK ?? 0), 0) + Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0) * pfo),
|
||||
case_2TY: frLignes.reduce((s, l) => s + (l.case_2TY ?? 0), 0),
|
||||
});
|
||||
}).finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(load, [activeId, activeView, annee]); // eslint-disable-line
|
||||
|
||||
/* ── Pagination détail ── */
|
||||
const detail = data?.detail ?? [];
|
||||
const {
|
||||
pagedItems: pagedDetail, page: detPage, setPage: setDetPage,
|
||||
pageSize: detPageSize, setPageSize: setDetPageSize,
|
||||
totalPages: detTotalPages, totalItems: detTotalItems, PAGE_SIZES,
|
||||
} = usePagination(detail, 'cl_pagesize_fiscal_detail', [activeTab]);
|
||||
|
||||
/* ── Exports ── */
|
||||
const dlBlob = (content, filename, type) => {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = filename; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const downloadCsv = () => {
|
||||
const token = localStorage.getItem('cl_token');
|
||||
const investisseurId = localStorage.getItem('cl_investisseur_id');
|
||||
const exportParams = activeView === 'all' ? { annee, scope: 'all' } : { annee };
|
||||
fetch(api.exportUrl('/taxreport/export', exportParams), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-Investisseur-Id': investisseurId,
|
||||
},
|
||||
})
|
||||
.then(r => r.blob())
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `2778-SD-${annee}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
};
|
||||
|
||||
const downloadJson = () => {
|
||||
if (!data) return;
|
||||
const payload = {
|
||||
annee: data.annee,
|
||||
recap: data.recap,
|
||||
cases: data.cases,
|
||||
detail: data.detail,
|
||||
pertes: data.pertes,
|
||||
pertesTotales: data.pertesTotales,
|
||||
};
|
||||
dlBlob(JSON.stringify(payload, null, 2), `2778-SD-${annee}.json`, 'application/json');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<h2><PageIcon name="tax" />Fiscalité — Récapitulatif fiscal</h2>
|
||||
<YearSelector annee={annee} setAnnee={setAnnee} availableYears={availableYears} />
|
||||
</div>
|
||||
|
||||
{loading || !data ? (
|
||||
<div className="card text-muted">Chargement…</div>
|
||||
) : (
|
||||
<>
|
||||
{!detailExpanded && !cerfaExpanded && data2042 && <div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Cases fiscales 2042 — synthèse {annee}</h3>
|
||||
<div className="kpi-grid">
|
||||
{data2042.case_2TT > 0 && <div className="kpi"><div className="label">Case 2TT — Prêts participatifs (FR)</div><div className="value">{fmtEUR(data2042.case_2TT)}</div></div>}
|
||||
{data2042.case_2TR > 0 && <div className="kpi"><div className="label">Case 2TR — Revenus fixes (FR + étranger)</div><div className="value">{fmtEUR(data2042.case_2TR)}</div></div>}
|
||||
<div className="kpi"><div className="label">Case 2BH — Base CSG/CRDS</div><div className="value">{fmtEUR(data2042.case_2BH)}</div></div>
|
||||
<div className="kpi"><div className="label">Case 2CK — Crédit d'impôt</div><div className="value" style={{ color: 'var(--success)' }}>{fmtEUR(data2042.case_2CK)}</div></div>
|
||||
{data2042.case_2TY > 0 && <div className="kpi"><div className="label">Case 2TY — Pertes en capital</div><div className="value danger">{fmtEUR(data2042.case_2TY)}</div></div>}
|
||||
</div>
|
||||
<p className="text-muted" style={{ marginTop: 12, fontSize: 12 }}>
|
||||
⚠ Cases indicatives combinant plateformes françaises (IFU automatique) et étrangères. Référez-vous à la notice 2041-GFI.
|
||||
</p>
|
||||
</div>}
|
||||
|
||||
{!detailExpanded && !cerfaExpanded && (
|
||||
<div className="dr-tabs">
|
||||
<button className={`dr-tab${activeTab === '2042' ? ' active' : ''}`} onClick={() => setActiveTab('2042')}>CERFA 2042</button>
|
||||
<button className={`dr-tab${activeTab === 'cerfa' ? ' active' : ''}`} onClick={() => setActiveTab('cerfa')}>CERFA 2561 (IFU)</button>
|
||||
{pfoAssujetti && <button className={`dr-tab${activeTab === '2778' ? ' active' : ''}`} onClick={() => setActiveTab('2778')}>CERFA 2778-SD</button>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === '2042' && !detailExpanded && !cerfaExpanded && data && (
|
||||
<Cerfa2042Preview annee={annee} activeView={activeView} pfoAssujetti={pfoAssujetti} />
|
||||
)}
|
||||
|
||||
{(activeTab === 'cerfa' || cerfaExpanded) && !detailExpanded && data && (
|
||||
<Cerfa2561Preview
|
||||
annee={annee} activeView={activeView} inline
|
||||
expanded={cerfaExpanded}
|
||||
onToggleExpand={() => { setCerfaExpanded(e => !e); setActiveTab('cerfa'); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === '2778' && pfoAssujetti && !detailExpanded && !cerfaExpanded && (
|
||||
<Cerfa2778Preview annee={annee} activeView={activeView} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '../../api.js';
|
||||
|
||||
export default function CreateUserSection({ onCreated }) {
|
||||
const [form, setForm] = useState({ email: '', password: '', displayName: '', role: 'user' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(null);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true); setErr(null); setSuccess(null);
|
||||
try {
|
||||
const created = await api.post('/admin/users', {
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
displayName: form.displayName || undefined,
|
||||
role: form.role,
|
||||
});
|
||||
setSuccess(`Utilisateur "${created.display_name || created.email}" créé avec succès.`);
|
||||
setForm({ email: '', password: '', displayName: '', role: 'user' });
|
||||
onCreated?.();
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 4px' }}>Créer un utilisateur</h3>
|
||||
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
|
||||
Créez un nouveau compte manuellement sur la plateforme.
|
||||
</p>
|
||||
|
||||
{success && <div className="success-msg" style={{ marginBottom: 16 }}>{success}</div>}
|
||||
{err && <div className="error" style={{ marginBottom: 16 }}>{err}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ maxWidth: 480 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label>Nom affiché</label>
|
||||
<input value={form.displayName} onChange={e => set('displayName', e.target.value)} placeholder="Prénom Nom" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Email *</label>
|
||||
<input type="email" required value={form.email} onChange={e => set('email', e.target.value)} placeholder="utilisateur@exemple.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Mot de passe *</label>
|
||||
<input type="password" required minLength={8} value={form.password} onChange={e => set('password', e.target.value)} placeholder="8 caractères minimum" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Rôle</label>
|
||||
<select value={form.role} onChange={e => set('role', e.target.value)}>
|
||||
<option value="user">Utilisateur</option>
|
||||
<option value="admin">Administrateur</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 20 }}>
|
||||
<button type="submit" className="primary" disabled={loading}>
|
||||
{loading ? 'Création…' : 'Créer l\'utilisateur'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { api } from '../../api.js';
|
||||
|
||||
const ICONS_BASE = '/api/icons-files/';
|
||||
|
||||
function IconPlus() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>;
|
||||
}
|
||||
function IconUpload() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>;
|
||||
}
|
||||
function IconDownload() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>;
|
||||
}
|
||||
function IconHistory() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.95"/></svg>;
|
||||
}
|
||||
|
||||
export default function IconsSection() {
|
||||
const [icons, setIcons] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
const [uploading, setUploading] = useState(null);
|
||||
const [history, setHistory] = useState(null); // { name, rows } | null
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
const [newFile, setNewFile] = useState(null);
|
||||
const [createErr, setCreateErr] = useState(null);
|
||||
const [createOk, setCreateOk] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setIcons(await api.get('/icons')); }
|
||||
catch { setErr('Erreur de chargement'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function handleReplace(name) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.svg,.png,.jpg,.jpeg,.webp';
|
||||
input.onchange = async () => {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
setUploading(name);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const token = localStorage.getItem('cl_token');
|
||||
const res = await fetch(`/api/icons/${name}`, {
|
||||
method: 'PUT',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) { const j = await res.json(); throw new Error(j.error); }
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setUploading(null); }
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
async function loadHistory(name) {
|
||||
try {
|
||||
const rows = await api.get(`/icons/${name}/history`);
|
||||
setHistory({ name, rows });
|
||||
} catch { setErr('Erreur historique'); }
|
||||
}
|
||||
|
||||
async function handleCreate(e) {
|
||||
e.preventDefault();
|
||||
setCreateErr(null); setCreateOk(null);
|
||||
if (!newName.trim()) return setCreateErr('Nom requis');
|
||||
if (!newFile) return setCreateErr('Fichier requis');
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('name', newName.trim().toLowerCase());
|
||||
fd.append('description', newDesc.trim());
|
||||
fd.append('file', newFile);
|
||||
const token = localStorage.getItem('cl_token');
|
||||
const res = await fetch('/api/icons', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: fd,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error);
|
||||
setCreateOk(`Icône "${json.name}" créée.`);
|
||||
setNewName(''); setNewDesc(''); setNewFile(null);
|
||||
setCreating(false);
|
||||
await load();
|
||||
} catch (e) { setCreateErr(e.message); }
|
||||
}
|
||||
|
||||
if (loading) return <p className="text-muted" style={{ padding: 24 }}>Chargement…</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="topbar" style={{ marginBottom: 20 }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0 }}>Bibliothèque d'icônes</h2>
|
||||
<p className="text-muted" style={{ margin: '4px 0 0', fontSize: 'var(--fs-sm)' }}>
|
||||
{icons.length} icône{icons.length !== 1 ? 's' : ''} — les noms sont les clés utilisées par l'application.
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => { setCreating(v => !v); setCreateErr(null); setCreateOk(null); }}>
|
||||
<IconPlus /> Nouvelle icône
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
|
||||
|
||||
{creating && (
|
||||
<div className="card" style={{ marginBottom: 20 }}>
|
||||
<h3 style={{ margin: '0 0 14px' }}>Nouvelle association nom / image</h3>
|
||||
<form onSubmit={handleCreate}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>
|
||||
Nom (slug) *
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="ex: taux-defaut"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
||||
/>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>lettres minuscules, chiffres, tirets</span>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="ex: Taux de défaut"
|
||||
value={newDesc}
|
||||
onChange={e => setNewDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>
|
||||
Fichier image * (SVG, PNG, JPG, WebP — 2 Mo max)
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".svg,.png,.jpg,.jpeg,.webp"
|
||||
onChange={e => setNewFile(e.target.files[0] || null)}
|
||||
/>
|
||||
{newFile && <span style={{ marginLeft: 8, fontSize: 12, color: 'var(--text-muted)' }}>{newFile.name}</span>}
|
||||
</div>
|
||||
{createErr && <div className="error" style={{ marginBottom: 8 }}>{createErr}</div>}
|
||||
{createOk && <div className="success" style={{ marginBottom: 8 }}>{createOk}</div>}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="submit" className="btn btn-primary">Créer</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setCreating(false)}>Annuler</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="icons-grid">
|
||||
{icons.map(icon => (
|
||||
<div key={icon.name} className="icon-card">
|
||||
<div className="icon-card-preview">
|
||||
<img
|
||||
src={`${ICONS_BASE}${icon.filename}`}
|
||||
alt={icon.name}
|
||||
style={{ width: 48, height: 48, objectFit: 'contain' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="icon-card-body">
|
||||
<span className="icon-card-name">{icon.name}</span>
|
||||
{icon.description && (
|
||||
<span className="icon-card-desc">{icon.description}</span>
|
||||
)}
|
||||
<span className="icon-card-file">{icon.filename}</span>
|
||||
</div>
|
||||
<div className="icon-card-actions">
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => handleReplace(icon.name)}
|
||||
disabled={uploading === icon.name}
|
||||
title="Remplacer l'image"
|
||||
>
|
||||
{uploading === icon.name ? '…' : <><IconUpload /> Remplacer</>}
|
||||
</button>
|
||||
<a
|
||||
className="btn btn-sm btn-ghost"
|
||||
href={`${ICONS_BASE}${icon.filename}`}
|
||||
download={icon.filename}
|
||||
title="Télécharger le fichier nettoyé"
|
||||
>
|
||||
<IconDownload />
|
||||
</a>
|
||||
<button
|
||||
className="btn btn-sm btn-ghost"
|
||||
onClick={() => history?.name === icon.name ? setHistory(null) : loadHistory(icon.name)}
|
||||
title="Historique des versions"
|
||||
>
|
||||
<IconHistory />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{history?.name === icon.name && (
|
||||
<div className="icon-history">
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)' }}>
|
||||
Historique ({history.rows.length} version{history.rows.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
{history.rows.length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>Aucune version précédente</span>
|
||||
) : (
|
||||
<div className="icon-history-list">
|
||||
{history.rows.map(h => (
|
||||
<div key={h.id} className="icon-history-row">
|
||||
<img
|
||||
src={`${ICONS_BASE}${h.filename}`}
|
||||
alt="prev"
|
||||
style={{ width: 28, height: 28, objectFit: 'contain', opacity: .7 }}
|
||||
/>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', flex: 1 }}>{h.filename}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{new Date(h.replaced_at).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { api } from '../../api.js';
|
||||
import { fmt, StatusBadge } from './adminHelpers.jsx';
|
||||
|
||||
const KNOWN_JOBS = [
|
||||
{ name: 'auto_statut_retard', label: 'Passage automatique en retard' },
|
||||
];
|
||||
|
||||
export default function JobLogsSection() {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [running, setRunning] = useState(null);
|
||||
const [runResult, setRunResult] = useState(null);
|
||||
const [err, setErr] = useState(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const PER_PAGE = 20;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.get('/admin/job-logs', { limit: PER_PAGE, offset: page * PER_PAGE });
|
||||
setLogs(data.rows);
|
||||
setTotal(data.total);
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setLoading(false); }
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const runJob = async (jobName) => {
|
||||
setRunning(jobName); setRunResult(null);
|
||||
try {
|
||||
const r = await api.post(`/admin/jobs/${jobName}/run`, {});
|
||||
setRunResult({ ok: true, msg: `Exécution terminée — ${r.nb_changes} modification(s)` });
|
||||
load();
|
||||
} catch (e) {
|
||||
setRunResult({ ok: false, msg: e.message });
|
||||
} finally { setRunning(null); }
|
||||
};
|
||||
|
||||
const pages = Math.ceil(total / PER_PAGE);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 4px' }}>Logs des jobs automatiques</h3>
|
||||
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
|
||||
Historique d'exécution des tâches planifiées et lancement manuel.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center', marginBottom: 20, flexWrap: 'wrap' }}>
|
||||
{KNOWN_JOBS.map(j => (
|
||||
<button
|
||||
key={j.name}
|
||||
className="btn btn-outline"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 7 }}
|
||||
disabled={running === j.name}
|
||||
onClick={() => runJob(j.name)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="4,2 14,8 4,14"/>
|
||||
</svg>
|
||||
{running === j.name ? 'Exécution…' : `Lancer : ${j.label}`}
|
||||
</button>
|
||||
))}
|
||||
{runResult && (
|
||||
<span style={{
|
||||
fontSize: 13, padding: '4px 12px', borderRadius: 6,
|
||||
background: runResult.ok ? 'rgba(34,197,94,.1)' : 'rgba(239,68,68,.1)',
|
||||
color: runResult.ok ? '#16a34a' : '#dc2626',
|
||||
border: `1px solid ${runResult.ok ? 'rgba(34,197,94,.3)' : 'rgba(239,68,68,.3)'}`,
|
||||
}}>
|
||||
{runResult.msg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && <p style={{ color: 'var(--text-muted)' }}>Chargement…</p>}
|
||||
{err && <p style={{ color: '#ef4444' }}>{err}</p>}
|
||||
{!loading && !err && !logs.length && <p style={{ color: 'var(--text-muted)' }}>Aucun log disponible.</p>}
|
||||
|
||||
{logs.length > 0 && (
|
||||
<>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 12 }}>
|
||||
{total} entrée{total > 1 ? 's' : ''} au total
|
||||
</p>
|
||||
<table style={{ fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th><th>Job</th><th>Statut</th>
|
||||
<th className="num">Modifs</th><th>Détails</th><th>Erreur</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map(l => (
|
||||
<tr key={l.id}>
|
||||
<td style={{ whiteSpace: 'nowrap', color: 'var(--text-muted)' }}>{fmt(l.run_at)}</td>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: 12 }}>{l.job_name}</td>
|
||||
<td><StatusBadge status={l.status} /></td>
|
||||
<td className="num">
|
||||
{l.nb_changes > 0
|
||||
? <span style={{ fontWeight: 700, color: '#f97316' }}>{l.nb_changes}</span>
|
||||
: <span style={{ color: 'var(--text-muted)' }}>0</span>
|
||||
}
|
||||
</td>
|
||||
<td style={{ maxWidth: 280, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={l.details || ''}>
|
||||
{l.details || <span style={{ color: 'var(--text-muted)' }}>—</span>}
|
||||
</td>
|
||||
<td style={{ color: '#ef4444', fontSize: 12 }}>
|
||||
{l.error_msg || <span style={{ color: 'var(--text-muted)' }}>—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{pages > 1 && (
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 16, alignItems: 'center' }}>
|
||||
<button className="btn btn-sm btn-outline" disabled={page === 0} onClick={() => setPage(p => p - 1)}>← Précédent</button>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>Page {page + 1} / {pages}</span>
|
||||
<button className="btn btn-sm btn-outline" disabled={page >= pages - 1} onClick={() => setPage(p => p + 1)}>Suivant →</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { api } from '../../api.js';
|
||||
import ConfirmModal from '../../components/ConfirmModal.jsx';
|
||||
import { fmt, Badge } from './adminHelpers.jsx';
|
||||
|
||||
export default function UsersSection({ currentUserId }) {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
const [confirmAction, setConfirmAction] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.get('/admin/users');
|
||||
setUsers(data);
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const toggleRole = (u) => {
|
||||
const newRole = u.role === 'admin' ? 'user' : 'admin';
|
||||
setConfirmAction({
|
||||
title: 'Changer le rôle',
|
||||
message: `Changer le rôle de ${u.display_name || u.email} → ${newRole === 'admin' ? 'Administrateur' : 'Utilisateur'} ?`,
|
||||
confirmLabel: 'Confirmer',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await api.patch(`/admin/users/${u.id}/role`, { role: newRole });
|
||||
load();
|
||||
} catch (e) { setErr('Erreur : ' + e.message); }
|
||||
finally { setConfirmAction(null); }
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const deleteUser = (u) => {
|
||||
setConfirmAction({
|
||||
title: 'Supprimer l\'utilisateur',
|
||||
message: `Supprimer définitivement ${u.display_name || u.email} ? Toutes ses données seront effacées.`,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await api.del(`/admin/users/${u.id}`);
|
||||
load();
|
||||
} catch (e) { setErr('Erreur : ' + e.message); }
|
||||
finally { setConfirmAction(null); }
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) return <p style={{ color: 'var(--text-muted)' }}>Chargement…</p>;
|
||||
if (err) return <p style={{ color: '#ef4444' }}>{err}</p>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 4px' }}>Comptes utilisateurs</h3>
|
||||
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
|
||||
{users.length} utilisateur{users.length !== 1 ? 's' : ''} enregistré{users.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 36 }}>ID</th>
|
||||
<th>Nom</th>
|
||||
<th>Email</th>
|
||||
<th>Rôle</th>
|
||||
<th>Créé le</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id}>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{u.id}</td>
|
||||
<td style={{ fontWeight: 500 }}>{u.display_name || <em style={{ color: 'var(--text-muted)' }}>—</em>}</td>
|
||||
<td>{u.email}</td>
|
||||
<td><Badge role={u.role} /></td>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmt(u.created_at)}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="btn btn-sm btn-outline"
|
||||
onClick={() => toggleRole(u)}
|
||||
disabled={u.id === currentUserId && u.role === 'admin'}
|
||||
title={u.id === currentUserId ? 'Vous ne pouvez pas vous rétrograder' : ''}
|
||||
>
|
||||
{u.role === 'admin' ? '→ Utilisateur' : '→ Admin'}
|
||||
</button>
|
||||
{u.id !== currentUserId && (
|
||||
<button className="btn btn-sm btn-danger" onClick={() => deleteUser(u)}>
|
||||
Supprimer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ConfirmModal
|
||||
open={!!confirmAction}
|
||||
title={confirmAction?.title}
|
||||
message={confirmAction?.message}
|
||||
confirmLabel={confirmAction?.confirmLabel}
|
||||
onConfirm={confirmAction?.onConfirm}
|
||||
onCancel={() => setConfirmAction(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/* ── Helpers partagés Admin ───────────────────────────────────── */
|
||||
export function fmt(iso) {
|
||||
if (!iso) return '—';
|
||||
const utc = iso.includes('T') || iso.endsWith('Z')
|
||||
? iso
|
||||
: iso.replace(' ', 'T') + 'Z';
|
||||
return new Date(utc).toLocaleString('fr-FR', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function Badge({ role }) {
|
||||
const isAdmin = role === 'admin';
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-block', padding: '2px 10px', borderRadius: 12,
|
||||
fontSize: 11, fontWeight: 700, letterSpacing: '.04em',
|
||||
background: isAdmin ? 'rgba(234,179,8,.15)' : 'rgba(100,116,139,.15)',
|
||||
color: isAdmin ? '#ca8a04' : '#64748b',
|
||||
border: `1px solid ${isAdmin ? 'rgba(234,179,8,.35)' : 'rgba(100,116,139,.25)'}`,
|
||||
}}>
|
||||
{isAdmin ? 'Admin' : 'Utilisateur'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }) {
|
||||
const ok = status === 'ok';
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-block', padding: '2px 10px', borderRadius: 12,
|
||||
fontSize: 11, fontWeight: 700,
|
||||
background: ok ? 'rgba(34,197,94,.12)' : 'rgba(239,68,68,.12)',
|
||||
color: ok ? '#16a34a' : '#dc2626',
|
||||
border: `1px solid ${ok ? 'rgba(34,197,94,.3)' : 'rgba(239,68,68,.3)'}`,
|
||||
}}>
|
||||
{ok ? 'OK' : 'Erreur'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTheme } from '../../context/ThemeContext.jsx';
|
||||
import { useUi } from '../../context/UiContext.jsx';
|
||||
import { api } from '../../api.js';
|
||||
|
||||
|
||||
/* ── Apparence — données ─────────────────────────────────────── */
|
||||
const THEMES = [
|
||||
{
|
||||
mode: 'light', label: 'Clair', desc: 'Interface lumineuse',
|
||||
preview: (
|
||||
<svg viewBox="0 0 56 36" width="56" height="36" aria-hidden="true">
|
||||
<rect width="56" height="36" rx="5" fill="#f0f4ff"/>
|
||||
<rect x="2" y="2" width="12" height="32" rx="3" fill="#1e3a8a"/>
|
||||
<rect x="16" y="2" width="38" height="8" rx="2" fill="#ffffff" opacity=".9"/>
|
||||
<rect x="16" y="12" width="38" height="5" rx="2" fill="#c7d2e8"/>
|
||||
<rect x="16" y="19" width="28" height="5" rx="2" fill="#c7d2e8"/>
|
||||
<rect x="16" y="26" width="20" height="5" rx="2" fill="#c7d2e8"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
mode: 'dark', label: 'Sombre', desc: 'Interface nocturne',
|
||||
preview: (
|
||||
<svg viewBox="0 0 56 36" width="56" height="36" aria-hidden="true">
|
||||
<rect width="56" height="36" rx="5" fill="#060e1f"/>
|
||||
<rect x="2" y="2" width="12" height="32" rx="3" fill="#0d1629"/>
|
||||
<rect x="16" y="2" width="38" height="8" rx="2" fill="#111c35" opacity=".9"/>
|
||||
<rect x="16" y="12" width="38" height="5" rx="2" fill="#1e3a6a"/>
|
||||
<rect x="16" y="19" width="28" height="5" rx="2" fill="#1e3a6a"/>
|
||||
<rect x="16" y="26" width="20" height="5" rx="2" fill="#1e3a6a"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
mode: 'system', label: 'Système', desc: 'Suit les préférences OS',
|
||||
preview: (
|
||||
<svg viewBox="0 0 56 36" width="56" height="36" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="split" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="50%" stopColor="#f0f4ff"/>
|
||||
<stop offset="50%" stopColor="#060e1f"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="56" height="36" rx="5" fill="url(#split)"/>
|
||||
<rect x="2" y="2" width="12" height="32" rx="3" fill="#1e3a8a"/>
|
||||
<rect x="16" y="2" width="18" height="8" rx="2" fill="#ffffff" opacity=".9"/>
|
||||
<rect x="36" y="2" width="18" height="8" rx="2" fill="#111c35" opacity=".9"/>
|
||||
<rect x="16" y="12" width="18" height="5" rx="2" fill="#c7d2e8"/>
|
||||
<rect x="36" y="12" width="18" height="5" rx="2" fill="#1e3a6a"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const FONTS = [
|
||||
{ scale: 'compact', label: 'Normal', desc: 'Interface compacte', sizes: { body: 12, table: 11 } },
|
||||
{ scale: 'medium', label: 'Moyen', desc: 'Taille intermédiaire', sizes: { body: 13, table: 12 } },
|
||||
{ scale: 'large', label: 'Grand', desc: 'Meilleure lisibilité', sizes: { body: 14, table: 13 } },
|
||||
];
|
||||
|
||||
|
||||
/* ── Icônes graphiques (couleurs) ────────────────────────────── */
|
||||
const ICONS_BASE = '/api/icons-files/';
|
||||
|
||||
function AppIcon({ filename, size = 22 }) {
|
||||
if (!filename) return null;
|
||||
return (
|
||||
<img
|
||||
src={`${ICONS_BASE}${filename}`}
|
||||
alt=""
|
||||
width={size}
|
||||
height={size}
|
||||
className="app-lib-icon"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ── Palette Material Design 2 ───────────────────────────────── */
|
||||
const MD_PALETTE = {
|
||||
'Rouge': { base: '#f44336', shades: { '50':'#ffebee','100':'#ffcdd2','200':'#ef9a9a','300':'#e57373','400':'#ef5350','500':'#f44336','600':'#e53935','700':'#d32f2f','800':'#c62828','900':'#b71c1c','A100':'#ff8a80','A200':'#ff5252','A400':'#ff1744','A700':'#d50000' } },
|
||||
'Rose': { base: '#e91e63', shades: { '50':'#fce4ec','100':'#f8bbd0','200':'#f48fb1','300':'#f06292','400':'#ec407a','500':'#e91e63','600':'#d81b60','700':'#c2185b','800':'#ad1457','900':'#880e4f','A100':'#ff80ab','A200':'#ff4081','A400':'#f50057','A700':'#c51162' } },
|
||||
'Violet': { base: '#9c27b0', shades: { '50':'#f3e5f5','100':'#e1bee7','200':'#ce93d8','300':'#ba68c8','400':'#ab47bc','500':'#9c27b0','600':'#8e24aa','700':'#7b1fa2','800':'#6a1b9a','900':'#4a148c','A100':'#ea80fc','A200':'#e040fb','A400':'#d500f9','A700':'#aa00ff' } },
|
||||
'Violet foncé': { base: '#673ab7', shades: { '50':'#ede7f6','100':'#d1c4e9','200':'#b39ddb','300':'#9575cd','400':'#7e57c2','500':'#673ab7','600':'#5e35b1','700':'#512da8','800':'#4527a0','900':'#311b92','A100':'#b388ff','A200':'#7c4dff','A400':'#651fff','A700':'#6200ea' } },
|
||||
'Indigo': { base: '#3f51b5', shades: { '50':'#e8eaf6','100':'#c5cae9','200':'#9fa8da','300':'#7986cb','400':'#5c6bc0','500':'#3f51b5','600':'#3949ab','700':'#303f9f','800':'#283593','900':'#1a237e','A100':'#8c9eff','A200':'#536dfe','A400':'#3d5afe','A700':'#304ffe' } },
|
||||
'Bleu': { base: '#2196f3', shades: { '50':'#e3f2fd','100':'#bbdefb','200':'#90caf9','300':'#64b5f6','400':'#42a5f5','500':'#2196f3','600':'#1e88e5','700':'#1976d2','800':'#1565c0','900':'#0d47a1','A100':'#82b1ff','A200':'#448aff','A400':'#2979ff','A700':'#2962ff' } },
|
||||
'Bleu clair': { base: '#03a9f4', shades: { '50':'#e1f5fe','100':'#b3e5fc','200':'#81d4fa','300':'#4fc3f7','400':'#29b6f6','500':'#03a9f4','600':'#039be5','700':'#0288d1','800':'#0277bd','900':'#01579b','A100':'#80d8ff','A200':'#40c4ff','A400':'#00b0ff','A700':'#0091ea' } },
|
||||
'Cyan': { base: '#00bcd4', shades: { '50':'#e0f7fa','100':'#b2ebf2','200':'#80deea','300':'#4dd0e1','400':'#26c6da','500':'#00bcd4','600':'#00acc1','700':'#0097a7','800':'#00838f','900':'#006064','A100':'#84ffff','A200':'#18ffff','A400':'#00e5ff','A700':'#00b8d4' } },
|
||||
'Sarcelle': { base: '#009688', shades: { '50':'#e0f2f1','100':'#b2dfdb','200':'#80cbc4','300':'#4db6ac','400':'#26a69a','500':'#009688','600':'#00897b','700':'#00796b','800':'#00695c','900':'#004d40','A100':'#a7ffeb','A200':'#64ffda','A400':'#1de9b6','A700':'#00bfa5' } },
|
||||
'Vert': { base: '#4caf50', shades: { '50':'#e8f5e9','100':'#c8e6c9','200':'#a5d6a7','300':'#81c784','400':'#66bb6a','500':'#4caf50','600':'#43a047','700':'#388e3c','800':'#2e7d32','900':'#1b5e20','A100':'#b9f6ca','A200':'#69f0ae','A400':'#00e676','A700':'#00c853' } },
|
||||
'Vert clair': { base: '#8bc34a', shades: { '50':'#f1f8e9','100':'#dcedc8','200':'#c5e1a5','300':'#aed581','400':'#9ccc65','500':'#8bc34a','600':'#7cb342','700':'#689f38','800':'#558b2f','900':'#33691e','A100':'#ccff90','A200':'#b2ff59','A400':'#76ff03','A700':'#64dd17' } },
|
||||
'Citron vert': { base: '#cddc39', shades: { '50':'#f9fbe7','100':'#f0f4c3','200':'#e6ee9c','300':'#dce775','400':'#d4e157','500':'#cddc39','600':'#c0ca33','700':'#afb42b','800':'#9e9d24','900':'#827717','A100':'#f4ff81','A200':'#eeff41','A400':'#c6ff00','A700':'#aeea00' } },
|
||||
'Jaune': { base: '#ffeb3b', shades: { '50':'#fffde7','100':'#fff9c4','200':'#fff59d','300':'#fff176','400':'#ffee58','500':'#ffeb3b','600':'#fdd835','700':'#fbc02d','800':'#f9a825','900':'#f57f17','A100':'#ffff8d','A200':'#ffff00','A400':'#ffea00','A700':'#ffd600' } },
|
||||
'Ambre': { base: '#ffc107', shades: { '50':'#fff8e1','100':'#ffecb3','200':'#ffe082','300':'#ffd54f','400':'#ffca28','500':'#ffc107','600':'#ffb300','700':'#ffa000','800':'#ff8f00','900':'#ff6f00','A100':'#ffe57f','A200':'#ffd740','A400':'#ffc400','A700':'#ffab00' } },
|
||||
'Orange': { base: '#ff9800', shades: { '50':'#fff3e0','100':'#ffe0b2','200':'#ffcc80','300':'#ffb74d','400':'#ffa726','500':'#ff9800','600':'#fb8c00','700':'#f57c00','800':'#ef6c00','900':'#e65100','A100':'#ffd180','A200':'#ffab40','A400':'#ff9100','A700':'#ff6d00' } },
|
||||
'Orange foncé': { base: '#ff5722', shades: { '50':'#fbe9e7','100':'#ffccbc','200':'#ffab91','300':'#ff8a65','400':'#ff7043','500':'#ff5722','600':'#f4511e','700':'#e64a19','800':'#d84315','900':'#bf360c','A100':'#ff9e80','A200':'#ff6e40','A400':'#ff3d00','A700':'#dd2c00' } },
|
||||
'Marron': { base: '#795548', shades: { '50':'#efebe9','100':'#d7ccc8','200':'#bcaaa4','300':'#a1887f','400':'#8d6e63','500':'#795548','600':'#6d4c41','700':'#5d4037','800':'#4e342e','900':'#3e2723' } },
|
||||
'Gris': { base: '#9e9e9e', shades: { '50':'#fafafa','100':'#f5f5f5','200':'#eeeeee','300':'#e0e0e0','400':'#bdbdbd','500':'#9e9e9e','600':'#757575','700':'#616161','800':'#424242','900':'#212121' } },
|
||||
'Gris bleu': { base: '#607d8b', shades: { '50':'#eceff1','100':'#cfd8dc','200':'#b0bec5','300':'#90a4ae','400':'#78909c','500':'#607d8b','600':'#546e7a','700':'#455a64','800':'#37474f','900':'#263238' } },
|
||||
};
|
||||
|
||||
/* Retourne true si la couleur hex est claire (pour adapter la couleur du texte) */
|
||||
function isLightColor(hex) {
|
||||
const r = parseInt(hex.slice(1,3),16);
|
||||
const g = parseInt(hex.slice(3,5),16);
|
||||
const b = parseInt(hex.slice(5,7),16);
|
||||
return (r * 299 + g * 587 + b * 114) / 1000 > 155;
|
||||
}
|
||||
|
||||
/* Retrouve la famille et la teinte d'un hex donné */
|
||||
function findInPalette(hex) {
|
||||
const h = hex.toLowerCase();
|
||||
for (const [family, { shades }] of Object.entries(MD_PALETTE)) {
|
||||
for (const [shade, color] of Object.entries(shades)) {
|
||||
if (color.toLowerCase() === h) return { family, shade };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* Mélange une couleur hex avec du blanc (factor 0=original, 1=blanc) */
|
||||
function lightenColor(hex, factor = 0.72) {
|
||||
const r = parseInt(hex.slice(1,3),16);
|
||||
const g = parseInt(hex.slice(3,5),16);
|
||||
const b = parseInt(hex.slice(5,7),16);
|
||||
const lr = Math.round(r + (255 - r) * factor);
|
||||
const lg = Math.round(g + (255 - g) * factor);
|
||||
const lb = Math.round(b + (255 - b) * factor);
|
||||
return '#' + [lr,lg,lb].map(v => v.toString(16).padStart(2,'0')).join('');
|
||||
}
|
||||
|
||||
/* ── Palettes suggérées ──────────────────────────────────────── */
|
||||
const SUGGESTED_PALETTES = [
|
||||
{ name: 'Classique', interets: '#2196f3', capital: '#4caf50', cashback: '#ffc107' },
|
||||
{ name: 'Indigo & Teal', interets: '#3949ab', capital: '#009688', cashback: '#fb8c00' },
|
||||
{ name: 'Nuit', interets: '#5c6bc0', capital: '#4dd0e1', cashback: '#ffca28' },
|
||||
{ name: 'Nature', interets: '#43a047', capital: '#039be5', cashback: '#ff9800' },
|
||||
{ name: 'Coucher de soleil', interets: '#ff5722', capital: '#1976d2', cashback: '#ffa000' },
|
||||
{ name: 'Violet', interets: '#7e57c2', capital: '#26a69a', cashback: '#ffb300' },
|
||||
{ name: 'Frais', interets: '#00acc1', capital: '#7cb342', cashback: '#ec407a' },
|
||||
{ name: 'Contraste', interets: '#e53935', capital: '#1e88e5', cashback: '#43a047' },
|
||||
{ name: 'Pastel', interets: '#29b6f6', capital: '#66bb6a', cashback: '#ffb74d' },
|
||||
{ name: 'Moderne', interets: '#9c27b0', capital: '#00bcd4', cashback: '#ff9800' },
|
||||
];
|
||||
|
||||
function PaletteSelector({ interets, capital, cashback, onSelect }) {
|
||||
const isActive = (p) =>
|
||||
p.interets === interets && p.capital === capital && p.cashback === cashback;
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<p style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)', fontWeight: 600, color: 'var(--text)' }}>
|
||||
Palettes suggérées
|
||||
</p>
|
||||
<div className="palette-grid">
|
||||
{SUGGESTED_PALETTES.map((p) => (
|
||||
<button
|
||||
key={p.name}
|
||||
type="button"
|
||||
className={`palette-card${isActive(p) ? ' active' : ''}`}
|
||||
onClick={() => onSelect(p)}
|
||||
title={p.name}
|
||||
>
|
||||
<div className="palette-swatches">
|
||||
<span className="palette-swatch" style={{ backgroundColor: p.interets }} />
|
||||
<span className="palette-swatch" style={{ backgroundColor: p.capital }} />
|
||||
<span className="palette-swatch" style={{ backgroundColor: p.cashback }} />
|
||||
</div>
|
||||
<span className="palette-name">{p.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartColorPicker({ value, onChange }) {
|
||||
const found = findInPalette(value);
|
||||
const [selectedFamily, setSelectedFamily] = useState(found?.family || 'Bleu');
|
||||
|
||||
// Sync la famille quand value change depuis l'extérieur (sélection palette)
|
||||
useEffect(() => {
|
||||
const f = findInPalette(value);
|
||||
if (f) setSelectedFamily(f.family);
|
||||
}, [value]);
|
||||
|
||||
const familyData = MD_PALETTE[selectedFamily];
|
||||
const shadeEntries = familyData ? Object.entries(familyData.shades) : [];
|
||||
|
||||
function handleFamilyChange(e) {
|
||||
const fam = e.target.value;
|
||||
setSelectedFamily(fam);
|
||||
// Auto-select shade 500 (or first available) when changing family
|
||||
const shades = MD_PALETTE[fam]?.shades || {};
|
||||
const target = shades['500'] || Object.values(shades)[5] || Object.values(shades)[0];
|
||||
if (target) onChange(target);
|
||||
}
|
||||
|
||||
const textColor = isLightColor(value) ? '#212121' : '#ffffff';
|
||||
|
||||
return (
|
||||
<div className="chart-color-picker">
|
||||
<div className="color-family-select-row">
|
||||
<span className="color-family-indicator" style={{ backgroundColor: familyData?.base || value }} />
|
||||
<select value={selectedFamily} onChange={handleFamilyChange}>
|
||||
{Object.keys(MD_PALETTE).map(name => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="color-shade-grid">
|
||||
{shadeEntries.map(([shade, hex]) => (
|
||||
<button
|
||||
key={shade}
|
||||
type="button"
|
||||
title={`${selectedFamily} ${shade} — ${hex}`}
|
||||
className={`color-shade-btn${value.toLowerCase() === hex.toLowerCase() ? ' selected' : ''}`}
|
||||
style={{ backgroundColor: hex }}
|
||||
onClick={() => onChange(hex)}
|
||||
>
|
||||
<span className="color-shade-label" style={{ color: isLightColor(hex) ? '#000' : '#fff' }}>
|
||||
{shade}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="color-preview-pair">
|
||||
<div className="color-preview-bar" style={{ backgroundColor: value, color: textColor }}>
|
||||
<span className="color-preview-label">Reçu</span>
|
||||
<span className="color-preview-hex">{value.toUpperCase()}</span>
|
||||
</div>
|
||||
<div className="color-preview-bar" style={{ backgroundColor: lightenColor(value), color: isLightColor(lightenColor(value)) ? '#333' : '#fff' }}>
|
||||
<span className="color-preview-label">Projeté</span>
|
||||
<span className="color-preview-hex">{lightenColor(value).toUpperCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppearanceSection() {
|
||||
const { mode, setMode } = useTheme();
|
||||
const { fontScale, setFontScale, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback } = useUi();
|
||||
const [libIcons, setLibIcons] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/icons').then(rows => {
|
||||
const m = {};
|
||||
rows.forEach(r => { m[r.name] = r.filename; });
|
||||
setLibIcons(m);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 4px' }}>Thème</h3>
|
||||
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
|
||||
Choisissez le thème visuel de l'application.
|
||||
</p>
|
||||
<div className="pref-options">
|
||||
{THEMES.map((t) => (
|
||||
<button key={t.mode} type="button"
|
||||
className={`pref-option${mode === t.mode ? ' active' : ''}`}
|
||||
onClick={() => setMode(t.mode)} aria-pressed={mode === t.mode}>
|
||||
{t.preview}
|
||||
<span style={{ fontWeight: 700, fontSize: 'var(--fs-sm)', marginTop: 4 }}>{t.label}</span>
|
||||
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>{t.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 4px' }}>Taille du texte</h3>
|
||||
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
|
||||
Le niveau <strong>Grand</strong> est recommandé pour les personnes malvoyantes.
|
||||
Les niveaux inférieurs permettent d'afficher plus de données à l'écran.
|
||||
</p>
|
||||
<div className="pref-options">
|
||||
{FONTS.map((f) => (
|
||||
<button key={f.scale} type="button"
|
||||
className={`pref-option${fontScale === f.scale ? ' active' : ''}`}
|
||||
onClick={() => setFontScale(f.scale)} aria-pressed={fontScale === f.scale}>
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4,
|
||||
width: '100%', padding: '8px 0 4px',
|
||||
borderBottom: '1px solid var(--border)', marginBottom: 4,
|
||||
}}>
|
||||
<span className="font-preview" style={{ fontSize: f.sizes.body }}>Aa — {f.label}</span>
|
||||
<span style={{ fontSize: f.sizes.table, color: 'var(--text-muted)' }}>
|
||||
Tableau {f.sizes.table}px · Corps {f.sizes.body}px
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontWeight: 700, fontSize: 'var(--fs-sm)', marginTop: 2 }}>{f.label}</span>
|
||||
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>{f.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 4px' }}>Couleurs des graphiques</h3>
|
||||
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
|
||||
Choisissez une palette ci-dessous ou personnalisez chaque série individuellement.
|
||||
</p>
|
||||
<PaletteSelector
|
||||
interets={chartInterets}
|
||||
capital={chartCapital}
|
||||
cashback={chartCashback}
|
||||
onSelect={(p) => { setChartInterets(p.interets); setChartCapital(p.capital); setChartCashback(p.cashback); }}
|
||||
/>
|
||||
<div className="chart-color-row">
|
||||
<div className="chart-color-item">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '1.1rem', fontWeight: 600 }}>
|
||||
<AppIcon filename={libIcons.interets} size={33} />
|
||||
Intérêts
|
||||
</label>
|
||||
<ChartColorPicker value={chartInterets} onChange={setChartInterets} />
|
||||
</div>
|
||||
<div className="chart-color-item">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '1.1rem', fontWeight: 600 }}>
|
||||
<AppIcon filename={libIcons.capital} size={33} />
|
||||
Capital
|
||||
</label>
|
||||
<ChartColorPicker value={chartCapital} onChange={setChartCapital} />
|
||||
</div>
|
||||
<div className="chart-color-item">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '1.1rem', fontWeight: 600 }}>
|
||||
<AppIcon filename={libIcons.cashback} size={33} />
|
||||
Cashback
|
||||
</label>
|
||||
<ChartColorPicker value={chartCashback} onChange={setChartCashback} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Icônes nav ──────────────────────────────────────────────── */
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../../api.js';
|
||||
|
||||
export default function CategoriesInvSection() {
|
||||
const [categoriesInv, setCategoriesInv] = useState([]);
|
||||
const [err, setErr] = useState(null);
|
||||
const [selectedCatInv, setSelectedCatInv] = useState(null);
|
||||
const [editingCatInv, setEditingCatInv] = useState(null);
|
||||
const [editingNomCatInv, setEditingNomCatInv] = useState('');
|
||||
const [newCatInvNom, setNewCatInvNom] = useState('');
|
||||
const [showNewCatInv, setShowNewCatInv] = useState(false);
|
||||
const [catGlobalOpen, setCatGlobalOpen] = useState(false);
|
||||
const [catPrivateOpen, setCatPrivateOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/categories-inv').then(data => {
|
||||
setCategoriesInv(data.sort((a, b) => b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const saveCatInv = async (nom) => {
|
||||
if (!nom.trim()) return;
|
||||
try {
|
||||
const row = await api.post('/categories-inv', { nom: nom.trim() });
|
||||
setCategoriesInv(prev => [...prev, row].sort((a, b) =>
|
||||
b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
|
||||
setShowNewCatInv(false); setNewCatInvNom(''); setErr(null);
|
||||
} catch (e) { setErr(e.message || 'Erreur'); }
|
||||
};
|
||||
const renameCatInv = async (id, nom) => {
|
||||
if (!nom.trim()) return;
|
||||
try {
|
||||
await api.put(`/categories-inv/${id}`, { nom: nom.trim() });
|
||||
setCategoriesInv(prev => prev.map(c => c.id === id ? { ...c, nom: nom.trim() } : c));
|
||||
setEditingCatInv(null);
|
||||
} catch (e) { setErr(e.message || 'Erreur'); }
|
||||
};
|
||||
const delCatInv = async (id) => {
|
||||
try {
|
||||
await api.del(`/categories-inv/${id}`);
|
||||
setCategoriesInv(prev => prev.filter(c => c.id !== id));
|
||||
if (selectedCatInv?.id === id) setSelectedCatInv(null);
|
||||
} catch (e) { setErr(e.message || 'Erreur'); }
|
||||
};
|
||||
|
||||
|
||||
const globalCats = categoriesInv.filter(c => c.is_global);
|
||||
const privateCats = categoriesInv.filter(c => !c.is_global);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<h3 style={{ margin: 0 }}>Mes catégories d'investissement</h3>
|
||||
</div>
|
||||
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
|
||||
|
||||
{/* Accordéon — Catégories globalement définies */}
|
||||
<div className="card" style={{ marginBottom: 10 }}>
|
||||
<button type="button"
|
||||
onClick={() => setCatGlobalOpen(o => !o)}
|
||||
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--text)' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 'var(--fs-base)' }}>
|
||||
Catégories globalement définies
|
||||
<span style={{ marginLeft: 8, fontWeight: 400, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
||||
({globalCats.length})
|
||||
</span>
|
||||
</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
|
||||
strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ transform: catGlobalOpen ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
{catGlobalOpen && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th className="num">Plateformes</th>
|
||||
<th className="num">Investissements</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{globalCats.length === 0 && (
|
||||
<tr><td colSpan={3} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune catégorie globale</td></tr>
|
||||
)}
|
||||
{globalCats.map(c => (
|
||||
<tr key={c.id}>
|
||||
<td><span style={{ fontWeight: 600 }}>{c.nom}</span></td>
|
||||
<td className="num">{c.nb_plateformes > 0 ? c.nb_plateformes : <span className="text-muted">—</span>}</td>
|
||||
<td className="num">{c.nb_investissements > 0 ? c.nb_investissements : <span className="text-muted">—</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Accordéon — Mes propres catégories */}
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<button type="button"
|
||||
onClick={() => setCatPrivateOpen(o => !o)}
|
||||
style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 8,
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--text)', textAlign: 'left' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 'var(--fs-base)' }}>
|
||||
Mes propres catégories
|
||||
<span style={{ marginLeft: 8, fontWeight: 400, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
||||
({privateCats.length})
|
||||
</span>
|
||||
</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
|
||||
strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ transform: catPrivateOpen ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
{catPrivateOpen && (
|
||||
<button className="primary" type="button" style={{ marginLeft: 12, flexShrink: 0 }}
|
||||
onClick={() => { setCatPrivateOpen(true); setShowNewCatInv(true); setNewCatInvNom(''); setErr(null); }}>
|
||||
+ Ajouter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{catPrivateOpen && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
{showNewCatInv && (
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<input autoFocus style={{ flex: 1 }} placeholder="Nom de la catégorie"
|
||||
value={newCatInvNom} onChange={e => setNewCatInvNom(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') saveCatInv(newCatInvNom); if (e.key === 'Escape') setShowNewCatInv(false); }} />
|
||||
<button className="primary" onClick={() => saveCatInv(newCatInvNom)}>Créer</button>
|
||||
<button onClick={() => setShowNewCatInv(false)}>Annuler</button>
|
||||
</div>
|
||||
)}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th className="num">Plateformes</th>
|
||||
<th className="num">Investissements</th>
|
||||
<th style={{ width: 80 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{privateCats.length === 0 && (
|
||||
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune catégorie personnelle</td></tr>
|
||||
)}
|
||||
{privateCats.map(c => (
|
||||
<tr key={c.id}>
|
||||
<td>
|
||||
{editingCatInv === c.id ? (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input autoFocus style={{ flex: 1 }} value={editingNomCatInv}
|
||||
onChange={e => setEditingNomCatInv(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') renameCatInv(c.id, editingNomCatInv); if (e.key === 'Escape') setEditingCatInv(null); }} />
|
||||
<button className="primary" onClick={() => renameCatInv(c.id, editingNomCatInv)}>OK</button>
|
||||
<button onClick={() => setEditingCatInv(null)}>✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontWeight: 600 }}>{c.nom}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="num">{c.nb_plateformes > 0 ? c.nb_plateformes : <span className="text-muted">—</span>}</td>
|
||||
<td className="num">{c.nb_investissements > 0 ? c.nb_investissements : <span className="text-muted">—</span>}</td>
|
||||
<td>
|
||||
{editingCatInv !== c.id && (
|
||||
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
|
||||
<button style={{ fontSize: 12, padding: '2px 8px' }}
|
||||
onClick={() => { setEditingCatInv(c.id); setEditingNomCatInv(c.nom); setErr(null); }}>
|
||||
Renommer
|
||||
</button>
|
||||
<button style={{ fontSize: 12, padding: '2px 8px', color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
||||
onClick={() => setConfirmDelete({
|
||||
title: 'Supprimer la catégorie',
|
||||
message: `Supprimer la catégorie "${c.nom}" ? Elle sera retirée de toutes les plateformes et investissements.`,
|
||||
confirmLabel: 'Supprimer',
|
||||
onConfirm: () => { delCatInv(c.id); setConfirmDelete(null); }
|
||||
})}>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { api } from '../../api.js';
|
||||
import { memberLabel } from '../../utils/format.js';
|
||||
import ConfirmModal from '../../components/ConfirmModal.jsx';
|
||||
import Modal from '../../components/Modal.jsx';
|
||||
|
||||
const EXONERATION_LABELS = {
|
||||
aucune: 'Aucune',
|
||||
pfnl_5ans: 'PFnl 5 ans',
|
||||
};
|
||||
const TYPE_COMPTE_LABELS = {
|
||||
compte_courant: 'Compte courant',
|
||||
pea_pme: 'PEA-PME',
|
||||
};
|
||||
|
||||
const EMPTY_COMPTE = { nom: '', type: 'compte_courant', investisseur_id: null, banque: '', exoneration_fiscale: 'aucune' };
|
||||
|
||||
function compteInvestisseur(c) {
|
||||
if (!c.investisseur_id) return null;
|
||||
return { id: c.investisseur_id, nom: c.investisseur_nom, prenom: c.investisseur_prenom, type: c.investisseur_type, type_fiscal: c.investisseur_type_fiscal };
|
||||
}
|
||||
|
||||
function CompteFormFields({ state, setter, investisseurs }) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label>Nom *</label>
|
||||
<input required value={state.nom}
|
||||
onChange={e => setter({ ...state, nom: e.target.value })}
|
||||
placeholder="ex. Compte courant BNP" />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label>Type *</label>
|
||||
<select value={state.type} onChange={e => setter({ ...state, type: e.target.value })}>
|
||||
<option value="compte_courant">Compte courant</option>
|
||||
<option value="pea_pme">PEA-PME</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Banque</label>
|
||||
<input value={state.banque}
|
||||
onChange={e => setter({ ...state, banque: e.target.value })}
|
||||
placeholder="ex. BNP Paribas" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>Détenteur</label>
|
||||
<select value={state.investisseur_id ?? ''}
|
||||
onChange={e => setter({ ...state, investisseur_id: e.target.value ? Number(e.target.value) : null })}>
|
||||
<option value="">— Non renseigné —</option>
|
||||
{investisseurs.map(inv => (
|
||||
<option key={inv.id} value={inv.id}>{memberLabel(inv)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Exonération fiscale</label>
|
||||
<select value={state.exoneration_fiscale ?? 'aucune'}
|
||||
onChange={e => setter({ ...state, exoneration_fiscale: e.target.value })}>
|
||||
<option value="aucune">Aucune exonération fiscale</option>
|
||||
<option value="pfnl_5ans">Exonération Impôts sur le revenu (PFNL) si détention 5 ans</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ComptesSection() {
|
||||
const [comptes, setComptes] = useState([]);
|
||||
const [investisseurs, setInvestisseurs] = useState([]);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const [cpts, invs] = await Promise.all([
|
||||
api.get('/comptes'),
|
||||
api.get('/investisseurs'),
|
||||
]);
|
||||
setComptes(cpts);
|
||||
setInvestisseurs(invs);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [newCompte, setNewCompte] = useState(EMPTY_COMPTE);
|
||||
const [editCompte, setEditCompte] = useState(null);
|
||||
const [err, setErr] = useState(null);
|
||||
const [confirmDel, setConfirmDel] = useState(null);
|
||||
|
||||
const openNew = () => { setNewCompte(EMPTY_COMPTE); setErr(null); setShowNew(true); };
|
||||
|
||||
const addCompte = async (e) => {
|
||||
e.preventDefault(); setErr(null);
|
||||
try {
|
||||
await api.post('/comptes', {
|
||||
nom: newCompte.nom,
|
||||
type: newCompte.type,
|
||||
banque: newCompte.banque || null,
|
||||
investisseur_id: newCompte.investisseur_id ? Number(newCompte.investisseur_id) : null,
|
||||
exoneration_fiscale: newCompte.exoneration_fiscale ?? 'aucune',
|
||||
});
|
||||
setShowNew(false); setNewCompte(EMPTY_COMPTE); reload();
|
||||
} catch (ex) { setErr(ex.message); }
|
||||
};
|
||||
|
||||
const saveEdit = async (e) => {
|
||||
e.preventDefault(); setErr(null);
|
||||
try {
|
||||
await api.put(`/comptes/${editCompte.id}`, {
|
||||
nom: editCompte.nom,
|
||||
type: editCompte.type,
|
||||
banque: editCompte.banque || null,
|
||||
investisseur_id: editCompte.investisseur_id ? Number(editCompte.investisseur_id) : null,
|
||||
exoneration_fiscale: editCompte.exoneration_fiscale ?? 'aucune',
|
||||
});
|
||||
setEditCompte(null); reload();
|
||||
} catch (ex) { setErr(ex.message); }
|
||||
};
|
||||
|
||||
const openEdit = (c) => {
|
||||
setErr(null);
|
||||
setEditCompte({ id: c.id, nom: c.nom, type: c.type, banque: c.banque || '', investisseur_id: c.investisseur_id ?? null, exoneration_fiscale: c.exoneration_fiscale ?? 'aucune' });
|
||||
};
|
||||
|
||||
const del = (c) => {
|
||||
setConfirmDel({
|
||||
message: `Supprimer le compte "${c.nom}" ?`,
|
||||
onConfirm: async () => {
|
||||
try { await api.del(`/comptes/${c.id}`); reload(); }
|
||||
catch (ex) { setErr(ex.message); }
|
||||
finally { setConfirmDel(null); }
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0 }}>Mes comptes courants</h3>
|
||||
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: '4px 0 0' }}>
|
||||
Comptes bancaires et enveloppes financières associés à vos investisseurs.
|
||||
</p>
|
||||
</div>
|
||||
<button className="primary" style={{ whiteSpace: 'nowrap' }} onClick={openNew}>
|
||||
+ Nouveau compte
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{err && <div className="error" style={{ marginBottom: 10 }}>{err}</div>}
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th style={{ width: '14%' }}>Type</th>
|
||||
<th style={{ width: '22%' }}>Détenteur</th>
|
||||
<th style={{ width: '16%' }}>Banque</th>
|
||||
<th style={{ width: '10%' }}>Exonération</th>
|
||||
<th style={{ width: 80 }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{comptes.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-muted" style={{ textAlign: 'center', fontStyle: 'italic' }}>
|
||||
Aucun compte défini.
|
||||
</td></tr>
|
||||
)}
|
||||
{comptes.map(c => {
|
||||
const inv = compteInvestisseur(c);
|
||||
return (
|
||||
<tr key={c.id}>
|
||||
<td style={{ fontWeight: 500 }}>{c.nom}</td>
|
||||
<td><span className="badge">{TYPE_COMPTE_LABELS[c.type] ?? c.type}</span></td>
|
||||
<td>{inv ? memberLabel(inv) : <span className="text-muted">—</span>}</td>
|
||||
<td>{c.banque || <span className="text-muted">—</span>}</td>
|
||||
<td>
|
||||
{c.exoneration_fiscale && c.exoneration_fiscale !== 'aucune' ? (
|
||||
<span
|
||||
title={EXONERATION_LABELS[c.exoneration_fiscale] ?? c.exoneration_fiscale}
|
||||
style={{ cursor: 'help', color: 'var(--success)', fontWeight: 600, fontSize: 'var(--fs-sm)' }}
|
||||
>Oui</span>
|
||||
) : (
|
||||
<span className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>Non</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button style={{ padding: '3px 10px', fontSize: 11 }} onClick={() => openEdit(c)}>Modifier</button>
|
||||
<button className="danger" style={{ padding: '3px 10px', fontSize: 11 }} onClick={() => del(c)}>✕</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* ── Modal création ── */}
|
||||
<Modal
|
||||
open={showNew}
|
||||
title="Nouveau compte"
|
||||
onClose={() => { setShowNew(false); setNewCompte(EMPTY_COMPTE); setErr(null); }}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%', gap: 8 }}>
|
||||
<button type="button" onClick={() => { setShowNew(false); setNewCompte(EMPTY_COMPTE); setErr(null); }}>Annuler</button>
|
||||
<button className="primary" form="form-new-compte" type="submit">Créer</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form id="form-new-compte" onSubmit={addCompte} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<CompteFormFields state={newCompte} setter={setNewCompte} investisseurs={investisseurs} />
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* ── Modal édition ── */}
|
||||
<Modal
|
||||
open={!!editCompte}
|
||||
title="Modifier le compte"
|
||||
onClose={() => { setEditCompte(null); setErr(null); }}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
||||
<button className="danger" type="button" onClick={() => { setEditCompte(null); del(comptes.find(c => c.id === editCompte?.id) ?? editCompte); }}>
|
||||
Supprimer
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" onClick={() => { setEditCompte(null); setErr(null); }}>Annuler</button>
|
||||
<button className="primary" form="form-edit-compte" type="submit">Enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form id="form-edit-compte" onSubmit={saveEdit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<CompteFormFields state={editCompte} setter={setEditCompte} investisseurs={investisseurs} />
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* ── ConfirmModal suppression ── */}
|
||||
<ConfirmModal
|
||||
open={!!confirmDel}
|
||||
message={confirmDel?.message}
|
||||
onConfirm={confirmDel?.onConfirm}
|
||||
onCancel={() => setConfirmDel(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '../../api.js';
|
||||
|
||||
function IconBroom() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 21l9-9"/><path d="M12.22 6.22L17 1.5l5.5 5.5-4.72 4.78"/><path d="M5 17c.5-2 2-3.5 4-4.5l3.5 3.5c-1 2-2.5 3.5-4.5 4"/></svg>;
|
||||
}
|
||||
|
||||
export default function DataCleanupSection() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showReprocessModal, setShowReprocessModal] = useState(false);
|
||||
const [loadingReprocess, setLoadingReprocess] = useState(false);
|
||||
const [showDiffereModal, setShowDiffereModal] = useState(false);
|
||||
const [loadingDiffere, setLoadingDiffere] = useState(false);
|
||||
const [showBackfillModal, setShowBackfillModal] = useState(false);
|
||||
const [loadingBackfill, setLoadingBackfill] = useState(false);
|
||||
const [successMsg, setSuccessMsg] = useState(null);
|
||||
const [errorMsg, setErrorMsg] = useState(null);
|
||||
|
||||
const handleReprocess = async () => {
|
||||
setLoadingReprocess(true);
|
||||
setErrorMsg(null);
|
||||
setSuccessMsg(null);
|
||||
try {
|
||||
const { updated } = await api.post('/remboursements/reprocess', {});
|
||||
setSuccessMsg(`${updated} remboursement${updated > 1 ? 's' : ''} recalculé${updated > 1 ? 's' : ''} avec succès.`);
|
||||
setShowReprocessModal(false);
|
||||
} catch (err) {
|
||||
setErrorMsg(err.message || 'Une erreur est survenue.');
|
||||
setShowReprocessModal(false);
|
||||
} finally {
|
||||
setLoadingReprocess(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReassign = async () => {
|
||||
setLoading(true);
|
||||
setErrorMsg(null);
|
||||
setSuccessMsg(null);
|
||||
try {
|
||||
await api.post('/investisseurs/reassign-to-principal', {});
|
||||
setSuccessMsg('Toutes les données ont été réaffectées au compte principal.');
|
||||
setShowModal(false);
|
||||
} catch (err) {
|
||||
setErrorMsg(err.message || 'Une erreur est survenue.');
|
||||
setShowModal(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFixDiffereDates = async () => {
|
||||
setLoadingDiffere(true);
|
||||
setErrorMsg(null);
|
||||
setSuccessMsg(null);
|
||||
try {
|
||||
const { updated, detail } = await api.post('/investissements/fix-differe-dates', {});
|
||||
if (updated === 0) {
|
||||
setSuccessMsg('Aucune date incohérente détectée sur les prêts différés.');
|
||||
} else {
|
||||
setSuccessMsg(
|
||||
`${updated} prêt${updated > 1 ? 's' : ''} différé${updated > 1 ? 's' : ''} corrigé${updated > 1 ? 's' : ''} : ` +
|
||||
detail.map(d => d.nom_projet).join(', ') + '.'
|
||||
);
|
||||
}
|
||||
setShowDiffereModal(false);
|
||||
} catch (err) {
|
||||
setErrorMsg(err.message || 'Une erreur est survenue.');
|
||||
setShowDiffereModal(false);
|
||||
} finally {
|
||||
setLoadingDiffere(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackfillComptes = async () => {
|
||||
setLoadingBackfill(true);
|
||||
setErrorMsg(null);
|
||||
setSuccessMsg(null);
|
||||
try {
|
||||
const { updated, total } = await api.post('/remboursements/backfill-comptes', {});
|
||||
if (updated === 0) {
|
||||
setSuccessMsg(`Aucun remboursement à corriger (${total} vérifié${total > 1 ? 's' : ''}).`);
|
||||
} else {
|
||||
setSuccessMsg(`${updated} remboursement${updated > 1 ? 's' : ''} mis à jour sur ${total} vérifié${total > 1 ? 's' : ''}.`);
|
||||
}
|
||||
setShowBackfillModal(false);
|
||||
} catch (err) {
|
||||
setErrorMsg(err.message || 'Une erreur est survenue.');
|
||||
setShowBackfillModal(false);
|
||||
} finally {
|
||||
setLoadingBackfill(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 4px' }}>Nettoyage de données</h3>
|
||||
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
|
||||
Opérations de maintenance sur les données du compte.
|
||||
</p>
|
||||
|
||||
{errorMsg && <div className="error" style={{ marginBottom: 12 }}>{errorMsg}</div>}
|
||||
{successMsg && <div className="success-msg" style={{ marginBottom: 12 }}>{successMsg}</div>}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '14px 16px', borderRadius: 8,
|
||||
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))',
|
||||
marginBottom: 10 }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
|
||||
Recalculer les champs fiscaux des remboursements
|
||||
</div>
|
||||
<div className="text-muted" style={{ fontSize: 12 }}>
|
||||
Recalcule prélèvements sociaux, impôt sur le revenu, intérêts nets et net reçu de tous
|
||||
vos remboursements selon la fiscalité de chaque plateforme et les taux PFU de l'année.
|
||||
Met également à jour les retraits automatiques associés.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
style={{ marginLeft: 16, whiteSpace: 'nowrap', flexShrink: 0 }}
|
||||
onClick={() => setShowReprocessModal(true)}
|
||||
disabled={loadingReprocess}
|
||||
>
|
||||
Recalculer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '14px 16px', borderRadius: 8,
|
||||
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))',
|
||||
marginBottom: 10 }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
|
||||
Corriger les dates des prêts différés
|
||||
</div>
|
||||
<div className="text-muted" style={{ fontSize: 12 }}>
|
||||
Recalcule la date de 1ère échéance et la date cible à partir de la date de souscription
|
||||
et de la durée prévue. Seuls les prêts dont les dates s'écartent de plus de 2 ans
|
||||
de la valeur calculée sont corrigés.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
style={{ marginLeft: 16, whiteSpace: 'nowrap', flexShrink: 0 }}
|
||||
onClick={() => setShowDiffereModal(true)}
|
||||
disabled={loadingDiffere}
|
||||
>
|
||||
Corriger
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '14px 16px', borderRadius: 8,
|
||||
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))',
|
||||
marginBottom: 10 }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
|
||||
Lier les remboursements aux comptes courants
|
||||
</div>
|
||||
<div className="text-muted" style={{ fontSize: 12 }}>
|
||||
Pour chaque remboursement en mode "Compte courant" sans compte lié, associe automatiquement
|
||||
le compte de l'investissement ou le premier compte courant du détenteur.
|
||||
Les remboursements déjà liés ou redirigés vers le porte-monnaie ne sont pas touchés.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
style={{ marginLeft: 16, whiteSpace: 'nowrap', flexShrink: 0 }}
|
||||
onClick={() => setShowBackfillModal(true)}
|
||||
disabled={loadingBackfill}
|
||||
>
|
||||
Lier
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '14px 16px', borderRadius: 8,
|
||||
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
|
||||
Réaffecter l'ensemble des données au compte principal
|
||||
</div>
|
||||
<div className="text-muted" style={{ fontSize: 12 }}>
|
||||
Investissements, dépôts/retraits — tous les enregistrements seront attribués au titulaire principal.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="danger"
|
||||
style={{ marginLeft: 16, whiteSpace: 'nowrap', flexShrink: 0 }}
|
||||
onClick={() => setShowModal(true)}
|
||||
disabled={loading}
|
||||
>
|
||||
Réaffecter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDiffereModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowDiffereModal(false)}>
|
||||
<div className="modal" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 16 }}>
|
||||
<h3 style={{ margin: 0 }}>Corriger les dates des prêts différés</h3>
|
||||
</div>
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }}>
|
||||
Pour chaque prêt de type <strong>différé</strong>, la date cible et la date de 1ère échéance
|
||||
seront recalculées comme suit :
|
||||
</p>
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.6, fontFamily: 'monospace', fontSize: 13,
|
||||
background: 'var(--surface-2, var(--bg))', padding: '8px 12px', borderRadius: 6,
|
||||
border: '1px solid var(--border)' }}>
|
||||
date souscription + durée (mois)
|
||||
</p>
|
||||
<p style={{ margin: '0 0 20px', lineHeight: 1.6 }} className="text-muted">
|
||||
La correction ne s'applique que si l'écart entre la date existante et la date calculée
|
||||
dépasse <strong>2 ans</strong>. Les simulations de remboursement associées ne sont pas
|
||||
recalculées automatiquement.
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={() => setShowDiffereModal(false)} disabled={loadingDiffere}>Annuler</button>
|
||||
<button className="primary" onClick={() => handleFixDiffereDates()} disabled={loadingDiffere}>
|
||||
{loadingDiffere ? 'Correction en cours…' : 'Confirmer la correction'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBackfillModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowBackfillModal(false)}>
|
||||
<div className="modal" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 16 }}>
|
||||
<h3 style={{ margin: 0 }}>Lier les remboursements aux comptes courants</h3>
|
||||
</div>
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }}>
|
||||
Pour chaque remboursement en mode <strong>"Compte courant de l'investisseur"</strong> sans compte lié, cette opération va :
|
||||
</p>
|
||||
<ul style={{ margin: '0 0 12px', paddingLeft: 20, lineHeight: 1.8, fontSize: 'var(--fs-sm)' }}>
|
||||
<li>Utiliser le compte défini sur l'investissement lié, si disponible</li>
|
||||
<li>Sinon, prendre le premier compte courant du détenteur</li>
|
||||
</ul>
|
||||
<p style={{ margin: '0 0 20px', lineHeight: 1.6 }} className="text-muted">
|
||||
Les remboursements déjà liés à un compte ou redirigés vers le porte-monnaie ne sont pas modifiés.
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={() => setShowBackfillModal(false)} disabled={loadingBackfill}>Annuler</button>
|
||||
<button className="primary" onClick={handleBackfillComptes} disabled={loadingBackfill}>
|
||||
{loadingBackfill ? 'Traitement en cours…' : 'Confirmer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showReprocessModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowReprocessModal(false)}>
|
||||
<div className="modal" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 16 }}>
|
||||
<h3 style={{ margin: 0 }}>Recalculer les remboursements</h3>
|
||||
</div>
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }}>
|
||||
Cette opération va recalculer pour <strong>tous vos remboursements</strong> les champs suivants :
|
||||
</p>
|
||||
<ul style={{ margin: '0 0 12px', paddingLeft: 20, lineHeight: 1.8, fontSize: 'var(--fs-sm)' }}>
|
||||
<li>Prélèvements sociaux et impôt sur le revenu (taux PFU de l'année)</li>
|
||||
<li>Taxe locale (pour les plateformes avec fiscalité locale)</li>
|
||||
<li>Intérêts nets et montant net reçu</li>
|
||||
<li>Montant des retraits automatiques associés</li>
|
||||
</ul>
|
||||
<p style={{ margin: '0 0 20px', lineHeight: 1.6 }} className="text-muted">
|
||||
Les valeurs saisies manuellement seront écrasées. Cette opération ne peut pas être annulée.
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={() => setShowReprocessModal(false)} disabled={loadingReprocess}>Annuler</button>
|
||||
<button className="primary" onClick={handleReprocess} disabled={loadingReprocess}>
|
||||
{loadingReprocess ? 'Recalcul en cours…' : 'Confirmer le recalcul'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{showModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowModal(false)}>
|
||||
<div className="modal" style={{ maxWidth: 440 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 16 }}>
|
||||
<h3 style={{ margin: 0, color: 'var(--danger, #ef4444)' }}>⚠ Action irréversible</h3>
|
||||
</div>
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }}>
|
||||
Cette action va réaffecter <strong>l'ensemble de vos investissements et mouvements financiers</strong> au compte principal.
|
||||
</p>
|
||||
<p style={{ margin: '0 0 20px', lineHeight: 1.6 }} className="text-muted">
|
||||
Les données actuellement rattachées à d'autres membres ou entreprises seront transférées au titulaire principal. Cette opération ne peut pas être annulée.
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={() => setShowModal(false)} disabled={loading}>Annuler</button>
|
||||
<button className="danger" onClick={handleReassign} disabled={loading}>
|
||||
{loading ? 'Réaffectation…' : 'Confirmer la réaffectation'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../../api.js';
|
||||
import { fmtDate } from '../../utils/format.js';
|
||||
import { useInvestisseur } from '../../context/InvestisseurContext.jsx';
|
||||
import ResultBanner from '../../components/ResultBanner.jsx';
|
||||
|
||||
function dlBlob(content, filename, type) {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = filename; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code ?? '—';
|
||||
const FISCALITE_LABELS = {
|
||||
flat_tax: 'Flat Tax',
|
||||
sans_fiscalite_locale: 'Sans fiscalité locale',
|
||||
avec_fiscalite_locale: 'Avec fiscalité locale',
|
||||
};
|
||||
|
||||
const METHODE_REMB_LABELS = {
|
||||
portefeuille: 'Porte-monnaie de la plateforme',
|
||||
compte_courant: "Compte courant de l'investisseur",
|
||||
choix_investisseur: "Au choix de l'investisseur (sur la plateforme)",
|
||||
};
|
||||
|
||||
/** Reconstruit un objet investisseur minimal depuis les colonnes dénormalisées de la plateforme */
|
||||
|
||||
function IconCSV() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="10" y1="9" x2="14" y2="9"/></svg>;
|
||||
}
|
||||
function IconXLS() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l2 2 4-4"/></svg>;
|
||||
}
|
||||
function IconJSON() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M8 13h1.5a1.5 1.5 0 0 1 0 3H8v-3z"/><path d="M14 13h2v1.5a1.5 1.5 0 0 1-3 0V13z"/></svg>;
|
||||
}
|
||||
function IconImport() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 14 12 9 17 14"/><line x1="12" y1="9" x2="12" y2="21"/></svg>;
|
||||
}
|
||||
|
||||
/* ── ExportDropdown ──────────────────────────────────────────── */
|
||||
function ExportDropdown({ disabled, onCSV, onXLS, onJSON, title = 'Exporter' }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
const choose = (fn) => { setOpen(false); fn(); };
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative' }}>
|
||||
<button type="button" className="icon-btn" disabled={disabled}
|
||||
onClick={() => setOpen(o => !o)} title={title}
|
||||
aria-haspopup="menu" aria-expanded={open}>
|
||||
<IconExport />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="export-dropdown" role="menu">
|
||||
<button role="menuitem" onClick={() => choose(onCSV)}>
|
||||
<IconCSV /><span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
|
||||
</button>
|
||||
<button role="menuitem" onClick={() => choose(onXLS)}>
|
||||
<IconXLS /><span><strong>Format Excel</strong><small>Fichier .xls natif Microsoft</small></span>
|
||||
</button>
|
||||
{onJSON && (
|
||||
<button role="menuitem" onClick={() => choose(onJSON)}>
|
||||
<IconJSON /><span><strong>Format JSON</strong><small>Données structurées</small></span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Helpers PFU ─────────────────────────────────────────────── */
|
||||
|
||||
|
||||
function IconUpload() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>;
|
||||
}
|
||||
|
||||
/* ── Imports — constantes ────────────────────────────────────── */
|
||||
const MODULES = {
|
||||
depots_retraits: {
|
||||
label: 'Dépôts / Retraits',
|
||||
required: ['date_operation', 'type', 'montant'],
|
||||
optional: ['plateforme_id', 'libelle', 'reference'],
|
||||
needsInvestisseur: true,
|
||||
},
|
||||
investissements: {
|
||||
label: 'Investissements',
|
||||
required: ['nom_projet', 'date_souscription', 'montant_investi'],
|
||||
optional: ['plateforme_id', 'emetteur', 'date_premiere_echeance', 'date_cible', 'taux_interet', 'duree_mois', 'type_remb', 'freq_interets', 'statut', 'reference'],
|
||||
needsInvestisseur: true,
|
||||
},
|
||||
remboursements: {
|
||||
label: 'Remboursements',
|
||||
required: ['investissement_id', 'date_remb'],
|
||||
optional: ['capital', 'interets_bruts', 'prelev_sociaux', 'prelev_forfaitaire', 'net_recu', 'statut'],
|
||||
needsInvestisseur: true,
|
||||
},
|
||||
plateformes: {
|
||||
label: 'Plateformes',
|
||||
required: ['nom'],
|
||||
optional: ['url', 'notes'],
|
||||
needsInvestisseur: false,
|
||||
note: 'Les plateformes dont le nom existe déjà seront ignorées (pas d\'écrasement).',
|
||||
},
|
||||
taux_pfu: {
|
||||
label: 'Flat Tax — Taux PFU',
|
||||
required: ['annee', 'pfu_total', 'impot_revenu', 'prelev_sociaux'],
|
||||
optional: [],
|
||||
needsInvestisseur: false,
|
||||
global: true,
|
||||
note: 'Table de référence globale. Si une année existe déjà, ses taux seront mis à jour (upsert).',
|
||||
},
|
||||
};
|
||||
|
||||
const MODULE_LABEL = {
|
||||
depots_retraits: 'Dépôts / Retraits',
|
||||
investissements: 'Investissements',
|
||||
remboursements: 'Remboursements',
|
||||
plateformes: 'Plateformes',
|
||||
taux_pfu: 'Flat Tax — Taux PFU',
|
||||
};
|
||||
|
||||
/* ── Imports — composant dossier ─────────────────────────────── */
|
||||
function DossierImport({
|
||||
activeId, navigate,
|
||||
dossierFile, setDossierFile,
|
||||
dossierPreview, setDossierPreview,
|
||||
dossierResult, setDossierResult,
|
||||
dossierBusy, setDossierBusy,
|
||||
dossierErr, setDossierErr,
|
||||
dossierInputRef, reloadHistory,
|
||||
}) {
|
||||
const missingInv = !activeId;
|
||||
|
||||
const onFileChange = (e) => {
|
||||
const f = e.target.files[0];
|
||||
setDossierFile(f || null);
|
||||
setDossierPreview(null); setDossierResult(null); setDossierErr(null);
|
||||
if (!f) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
try {
|
||||
const parsed = JSON.parse(ev.target.result);
|
||||
if (parsed.type !== 'dossier_investissement') {
|
||||
setDossierErr('Ce fichier n\'est pas un dossier investissement valide (type incorrect).');
|
||||
return;
|
||||
}
|
||||
setDossierPreview(parsed);
|
||||
} catch { setDossierErr('Fichier JSON invalide — vérifiez la syntaxe.'); }
|
||||
};
|
||||
reader.readAsText(f);
|
||||
};
|
||||
|
||||
const onImport = async () => {
|
||||
if (!dossierPreview) return;
|
||||
setDossierBusy(true); setDossierErr(null); setDossierResult(null);
|
||||
try {
|
||||
const r = await api.post('/imports/dossier', { dossier: dossierPreview });
|
||||
setDossierResult(r);
|
||||
setDossierFile(null); setDossierPreview(null);
|
||||
if (dossierInputRef.current) dossierInputRef.current.value = '';
|
||||
reloadHistory();
|
||||
} catch (e) { setDossierErr(e.message); }
|
||||
finally { setDossierBusy(false); }
|
||||
};
|
||||
|
||||
const dp = dossierPreview;
|
||||
const inv = dp?.investissement;
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 4px' }}>Import — Dossier investissement</h3>
|
||||
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', marginBottom: 12 }}>
|
||||
Restaure ou migre un dossier complet (investissement + remboursements + historique) depuis un fichier
|
||||
<code style={{ margin: '0 4px' }}>.json</code> exporté par cette application.
|
||||
Si le dossier existe déjà, il sera mis à jour ; sinon il sera créé.
|
||||
</p>
|
||||
|
||||
{missingInv && (
|
||||
<div className="error" style={{ marginBottom: 10 }}>
|
||||
Sélectionnez un investisseur actif avant d'importer un dossier.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="row" style={{ gap: 10, alignItems: 'flex-end' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label>Fichier dossier <code>.json</code></label>
|
||||
<input ref={dossierInputRef} type="file" accept=".json"
|
||||
disabled={missingInv} onChange={onFileChange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dossierErr && <div className="error" style={{ marginTop: 10 }}>{dossierErr}</div>}
|
||||
|
||||
{dp && inv && (
|
||||
<div style={{ marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 14 }}>
|
||||
<h4 style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)' }}>Aperçu du dossier</h4>
|
||||
<table style={{ marginBottom: 0 }}>
|
||||
<tbody>
|
||||
<tr><td style={{ width: 200 }}>Projet</td><td><strong>{inv.nom_projet}</strong></td></tr>
|
||||
<tr><td>Plateforme</td><td>{dp.plateforme?.nom}</td></tr>
|
||||
<tr><td>Date souscription</td><td>{fmtDate(inv.date_souscription)}</td></tr>
|
||||
<tr><td>Montant investi</td><td>{inv.montant_investi} €</td></tr>
|
||||
<tr><td>Statut</td><td>{inv.statut}</td></tr>
|
||||
<tr><td>Remboursements</td><td>{dp.remboursements?.length ?? 0} enregistrement(s)</td></tr>
|
||||
<tr><td>Réinvestissements</td><td>{dp.reinvestissements?.length ?? 0} enregistrement(s)</td></tr>
|
||||
<tr><td>Projections</td><td>{dp.projections?.length ?? 0} échéance(s)</td></tr>
|
||||
<tr><td>Historique</td><td>{dp.historique?.length ?? 0} entrée(s)</td></tr>
|
||||
<tr><td>Exporté le</td><td className="text-muted" style={{ fontSize: 11 }}>{dp.exported_at}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => { setDossierFile(null); setDossierPreview(null); if (dossierInputRef.current) dossierInputRef.current.value = ''; }}>
|
||||
Annuler
|
||||
</button>
|
||||
<button className="primary" onClick={onImport} disabled={dossierBusy || missingInv}>
|
||||
{dossierBusy ? '…' : 'Importer ce dossier'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dossierResult && (
|
||||
<div className="success-msg" style={{ marginTop: 12 }}>
|
||||
{dossierResult.action === 'created' ? '✔ Dossier créé avec succès.' : '✔ Dossier mis à jour avec succès.'}
|
||||
{' '}
|
||||
<button
|
||||
style={{ marginLeft: 8, fontSize: 'var(--fs-xs)', padding: '2px 8px' }}
|
||||
onClick={() => navigate(`/investissements/${dossierResult.investissementId}`)}
|
||||
>
|
||||
Ouvrir le dossier →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Imports — section principale ────────────────────────────── */
|
||||
export default function ImportsSection() {
|
||||
const { activeId } = useInvestisseur();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [module, setModule] = useState('depots_retraits');
|
||||
const [file, setFile] = useState(null);
|
||||
const [preview, setPreview] = useState(null);
|
||||
const [mapping, setMapping] = useState({});
|
||||
const [defaults, setDefaults] = useState({});
|
||||
const [plats, setPlats] = useState([]);
|
||||
const [investissements, setInvestissements] = useState([]);
|
||||
const [history, setHistory] = useState([]);
|
||||
const [result, setResult] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
const [dossierFile, setDossierFile] = useState(null);
|
||||
const [dossierPreview, setDossierPreview] = useState(null);
|
||||
const [dossierResult, setDossierResult] = useState(null);
|
||||
const [dossierBusy, setDossierBusy] = useState(false);
|
||||
const [dossierErr, setDossierErr] = useState(null);
|
||||
const dossierInputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/imports/history').then(setHistory).catch(() => {});
|
||||
api.get('/plateformes').then(setPlats).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeId) return;
|
||||
api.get('/investissements').then(setInvestissements).catch(() => {});
|
||||
}, [activeId]);
|
||||
|
||||
const def = MODULES[module];
|
||||
const allTargets = def ? [...def.required, ...def.optional] : [];
|
||||
const missingInv = def?.needsInvestisseur && !activeId;
|
||||
|
||||
const onPreview = async () => {
|
||||
if (!file) return;
|
||||
setBusy(true); setErr(null); setResult(null);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const r = await api.upload('/imports/preview', fd);
|
||||
setPreview(r);
|
||||
const auto = {};
|
||||
for (const t of allTargets) {
|
||||
const col = r.headers.find(h => h.toLowerCase().replace(/\W/g, '_') === t);
|
||||
if (col) auto[t] = col;
|
||||
}
|
||||
setMapping(auto);
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const apply = async () => {
|
||||
setBusy(true); setErr(null);
|
||||
try {
|
||||
const r = await api.post('/imports/apply', {
|
||||
tempId: preview.tempId, module, mapping, defaults,
|
||||
originalFilename: file?.name ?? preview.filename,
|
||||
});
|
||||
setResult({
|
||||
ok: true,
|
||||
msg: `✔ Import terminé : ${r.inserted} / ${r.total} lignes insérées${r.skipped > 0 ? `, ${r.skipped} ignorées` : ''}.${r.errors?.length > 0 ? ` (${r.errors.length} avertissement(s))` : ''}`,
|
||||
});
|
||||
setPreview(null); setFile(null); setMapping({}); setDefaults({});
|
||||
api.get('/imports/history').then(setHistory).catch(() => {});
|
||||
if (module === 'plateformes') api.get('/plateformes').then(setPlats).catch(() => {});
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 4px' }}>1. Fichier source</h3>
|
||||
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
|
||||
Importez des données depuis un fichier Excel, CSV ou JSON.
|
||||
</p>
|
||||
<div className="row">
|
||||
<div>
|
||||
<label>Module cible</label>
|
||||
<select value={module} onChange={e => {
|
||||
setModule(e.target.value);
|
||||
setPreview(null); setMapping({}); setResult(null); setErr(null);
|
||||
}}>
|
||||
{Object.entries(MODULES).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ flex: 2 }}>
|
||||
<label>Fichier .xlsx, .csv ou .json</label>
|
||||
<input type="file" accept=".xlsx,.xls,.csv,.json" onChange={e => {
|
||||
setFile(e.target.files[0]);
|
||||
setPreview(null); setResult(null); setErr(null);
|
||||
}} />
|
||||
</div>
|
||||
<div>
|
||||
<button className="primary" onClick={onPreview} disabled={!file || busy || missingInv}>
|
||||
{busy ? '…' : 'Analyser'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{def?.note && (
|
||||
<div className="import-module-note">
|
||||
{def.global && (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ flexShrink: 0, marginTop: 1, color: 'var(--warning)' }}>
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
)}
|
||||
{def.note}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{missingInv && (
|
||||
<div className="error" style={{ marginTop: 10 }}>
|
||||
Sélectionnez un investisseur actif avant d'importer ce module.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && <div className="error" style={{ marginTop: 12 }}>{err}</div>}
|
||||
<ResultBanner result={result} onDismiss={() => setResult(null)} style={{ marginTop: 12 }} />
|
||||
</div>
|
||||
|
||||
{preview && (
|
||||
<>
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>2. Mappage des colonnes</h3>
|
||||
<p className="text-muted" style={{ fontSize: 12 }}>
|
||||
Fichier : <strong>{preview.filename}</strong> — feuille <em>{preview.sheetName}</em> — {preview.allRowCount} lignes.
|
||||
{' '}Champs marqués <span style={{ color: 'var(--danger)' }}>*</span> obligatoires.
|
||||
{' '}Si la colonne n'existe pas, fournissez une valeur par défaut.
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Champ cible</th><th>Colonne Excel</th><th>Valeur par défaut</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allTargets.map(t => {
|
||||
const isReq = def.required.includes(t);
|
||||
return (
|
||||
<tr key={t}>
|
||||
<td>
|
||||
<code style={{ fontSize: 11 }}>{t}</code>
|
||||
{isReq && <span style={{ color: 'var(--danger)' }}> *</span>}
|
||||
</td>
|
||||
<td>
|
||||
<select value={mapping[t] || ''} onChange={e => setMapping({ ...mapping, [t]: e.target.value })}>
|
||||
<option value="">— ignorer —</option>
|
||||
{preview.headers.map(h => <option key={h} value={h}>{h}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{t === 'plateforme_id' ? (
|
||||
<select value={defaults[t] || ''} onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
|
||||
</select>
|
||||
) : t === 'investissement_id' ? (
|
||||
<select value={defaults[t] || ''} onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
{investissements.map(i => <option key={i.id} value={i.id}>{i.nom_projet}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input value={defaults[t] || ''} onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}
|
||||
placeholder={t === 'statut' ? 'ex. en_cours' : t === 'type' ? 'ex. depot' : ''} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={() => { setPreview(null); setMapping({}); }}>Annuler</button>
|
||||
<button className="primary" onClick={apply} disabled={busy || missingInv}>
|
||||
{busy ? '…' : `Importer ${preview.allRowCount} lignes`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Aperçu (10 premières lignes)</h3>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table>
|
||||
<thead><tr>{preview.headers.map(h => <th key={h}>{h}</th>)}</tr></thead>
|
||||
<tbody>
|
||||
{preview.sampleRows.map((r, i) => (
|
||||
<tr key={i}>{preview.headers.map(h => <td key={h}>{String(r[h] ?? '')}</td>)}</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DossierImport
|
||||
activeId={activeId}
|
||||
navigate={navigate}
|
||||
dossierFile={dossierFile} setDossierFile={setDossierFile}
|
||||
dossierPreview={dossierPreview} setDossierPreview={setDossierPreview}
|
||||
dossierResult={dossierResult} setDossierResult={setDossierResult}
|
||||
dossierBusy={dossierBusy} setDossierBusy={setDossierBusy}
|
||||
dossierErr={dossierErr} setDossierErr={setDossierErr}
|
||||
dossierInputRef={dossierInputRef}
|
||||
reloadHistory={() => api.get('/imports/history').then(setHistory).catch(() => {})}
|
||||
/>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Historique des imports</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th><th>Module</th><th>Fichier</th>
|
||||
<th className="num">Total</th><th className="num">OK</th><th className="num">KO</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-muted" style={{ textAlign: 'center' }}>Aucun import</td></tr>
|
||||
)}
|
||||
{history.map(h => (
|
||||
<tr key={h.id}>
|
||||
<td>{fmtDate(h.created_at)}</td>
|
||||
<td>{MODULE_LABEL[h.module] ?? h.module}</td>
|
||||
<td className="text-muted" style={{ fontSize: 11 }}>{h.filename}</td>
|
||||
<td className="num">{h.rows_total}</td>
|
||||
<td className="num" style={{ color: 'var(--success)' }}>{h.rows_inserted}</td>
|
||||
<td className="num" style={{ color: h.rows_skipped > 0 ? 'var(--warning)' : undefined }}>{h.rows_skipped}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState } from 'react';
|
||||
import { useUi } from '../../context/UiContext.jsx';
|
||||
|
||||
export default function MaFiscaliteSection() {
|
||||
const { pfoAssujetti, setPfoAssujetti } = useUi();
|
||||
const [showPfoDetail, setShowPfoDetail] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ margin: '0 0 6px', fontSize: 18 }}>Ma fiscalité</h2>
|
||||
<p style={{ margin: '0 0 24px', color: 'var(--text-muted)', fontSize: 13 }}>
|
||||
Paramètres fiscaux personnels applicables à vos revenus de placements.
|
||||
</p>
|
||||
|
||||
<div style={{ fontWeight: 700, fontSize: 'var(--fs-md)', marginBottom: 10 }}>
|
||||
Fiscalité des plateformes étrangères
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 16, borderLeft: `4px solid ${pfoAssujetti ? 'var(--primary)' : 'var(--border)'}`, transition: 'border-color .2s' }}>
|
||||
|
||||
{/* Ligne titre + chevron + toggle */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPfoDetail(v => !v)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex', alignItems: 'center', color: 'var(--text)' }}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ transform: showPfoDetail ? 'rotate(180deg)' : 'none', transition: 'transform .2s', marginRight: 6, flexShrink: 0 }}>
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
<span style={{ fontWeight: 700, fontSize: 'var(--fs-base)' }}>
|
||||
CERFA 2778-SD — Prélèvement Forfaitaire Obligatoire (PFO) pour des revenus de source étrangère
|
||||
</span>
|
||||
</button>
|
||||
<div style={{ marginLeft: 'auto', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 'var(--fs-xs)', fontWeight: 600, color: pfoAssujetti ? 'var(--primary)' : 'var(--text-muted)' }}>
|
||||
{pfoAssujetti ? 'Activé' : 'Désactivé'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPfoAssujetti(!pfoAssujetti)}
|
||||
style={{
|
||||
width: 48, height: 26, borderRadius: 13, border: 'none',
|
||||
background: pfoAssujetti ? 'var(--primary)' : 'var(--border)',
|
||||
cursor: 'pointer', position: 'relative', transition: 'background .2s',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
position: 'absolute', top: 3,
|
||||
left: pfoAssujetti ? 25 : 3,
|
||||
width: 20, height: 20, borderRadius: '50%',
|
||||
background: '#fff', transition: 'left .2s',
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.2)',
|
||||
}} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showPfoDetail && (
|
||||
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)', lineHeight: 1.6 }}>
|
||||
Les intérêts perçus via des plateformes <strong>étrangères</strong> sont soumis à un prélèvement
|
||||
forfaitaire obligatoire non libératoire de <strong>12,8 %</strong> (+ prélèvements sociaux),
|
||||
à déclarer mensuellement via le formulaire <strong>2778-SD</strong> dans les 15 premiers jours
|
||||
du mois suivant l'encaissement.
|
||||
</div>
|
||||
<div style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)', lineHeight: 1.6, marginTop: 8 }}>
|
||||
Ce prélèvement s'applique aux personnes physiques fiscalement domiciliées en France dont
|
||||
le <strong>revenu fiscal de référence</strong> de l'avant-dernière année est égal ou supérieur à :
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 8, flexWrap: 'wrap' }}>
|
||||
<div style={{
|
||||
padding: '6px 14px', borderRadius: 20,
|
||||
background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.2)',
|
||||
fontSize: 'var(--fs-sm)', fontWeight: 600,
|
||||
}}>25 000 € — célibataire, divorcé ou veuf</div>
|
||||
<div style={{
|
||||
padding: '6px 14px', borderRadius: 20,
|
||||
background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.2)',
|
||||
fontSize: 'var(--fs-sm)', fontWeight: 600,
|
||||
}}>50 000 € — couple marié ou pacsé</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 8 }}>
|
||||
En dessous de ces seuils, vous êtes dispensé du PFO — mais les prélèvements sociaux restent dus.
|
||||
Le PFO versé via la 2778-SD constitue un simple acompte d'impôt sur le revenu,
|
||||
imputable sur l'impôt définitif calculé lors de la déclaration 2042.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../../api.js';
|
||||
import InvSelect from '../../components/InvSelect.jsx';
|
||||
import Modal from '../../components/Modal.jsx';
|
||||
import ConfirmModal from '../../components/ConfirmModal.jsx';
|
||||
import CountrySelect, { COUNTRIES, FlagIcon } from '../../components/CountrySelect.jsx';
|
||||
import ResultBanner from '../../components/ResultBanner.jsx';
|
||||
import { useInvestisseur } from '../../context/InvestisseurContext.jsx';
|
||||
import { memberLabel, fmtDate } from '../../utils/format.js';
|
||||
|
||||
function dlBlob(content, filename, type) {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = filename; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code ?? '—';
|
||||
const FISCALITE_LABELS = {
|
||||
flat_tax: 'Flat Tax',
|
||||
sans_fiscalite_locale: 'Sans fiscalité locale',
|
||||
avec_fiscalite_locale: 'Avec fiscalité locale',
|
||||
};
|
||||
|
||||
const METHODE_REMB_LABELS = {
|
||||
portefeuille: 'Porte-monnaie de la plateforme',
|
||||
compte_courant: "Compte courant de l'investisseur",
|
||||
choix_investisseur: "Au choix de l'investisseur (sur la plateforme)",
|
||||
};
|
||||
|
||||
/** Reconstruit un objet investisseur minimal depuis les colonnes dénormalisées de la plateforme */
|
||||
function platInvestisseur(p) {
|
||||
if (!p.investisseur_id) return null;
|
||||
return { id: p.investisseur_id, nom: p.investisseur_nom, prenom: p.investisseur_prenom, type: p.investisseur_type, type_fiscal: p.investisseur_type_fiscal };
|
||||
}
|
||||
|
||||
function fmtFiscalite(p) {
|
||||
if (!p.fiscalite) return '—';
|
||||
const base = FISCALITE_LABELS[p.fiscalite] ?? p.fiscalite;
|
||||
if (p.fiscalite === 'avec_fiscalite_locale' && p.taux_fiscalite_locale != null) {
|
||||
return `${base} (${p.taux_fiscalite_locale} %)`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
const EMPTY_PLAT = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', methode_remboursement: 'portefeuille', investisseur_id: null, date_ouverture: '', logo_filename: null, type_pret_defaut: '', freq_interets_defaut: '', referentiel_id: null };
|
||||
|
||||
const LOGO_BASE = (import.meta.env.VITE_API_URL || '/api').replace(/\/api$/, '') + '/api/logos/';
|
||||
const logoUrl = (filename) => filename ? LOGO_BASE + filename : null;
|
||||
|
||||
function applyDomiciliationChange(state, newDomicil) {
|
||||
const next = { ...state, domiciliation: newDomicil };
|
||||
if (newDomicil === 'FR') {
|
||||
next.fiscalite = 'flat_tax';
|
||||
next.taux_fiscalite_locale = '';
|
||||
} else if (state.fiscalite === 'flat_tax') {
|
||||
next.fiscalite = 'sans_fiscalite_locale';
|
||||
next.taux_fiscalite_locale = '';
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function applyFiscaliteChange(state, newFiscalite) {
|
||||
const next = { ...state, fiscalite: newFiscalite };
|
||||
if (newFiscalite !== 'avec_fiscalite_locale') next.taux_fiscalite_locale = '';
|
||||
return next;
|
||||
}
|
||||
|
||||
function platsToCSV(plats) {
|
||||
const BOM = '';
|
||||
const sep = ';';
|
||||
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
|
||||
const headers = ['ID', 'Nom', 'URL', 'Domiciliation', 'Fiscalité', 'Taux fiscal local (%)', 'Créé le'];
|
||||
const rows = plats.map(p => [
|
||||
p.id, p.nom, p.url || '',
|
||||
countryLabel(p.domiciliation),
|
||||
FISCALITE_LABELS[p.fiscalite] ?? p.fiscalite ?? '',
|
||||
p.taux_fiscalite_locale ?? '',
|
||||
p.created_at ? new Date(p.created_at).toLocaleDateString('fr-FR') : '',
|
||||
]);
|
||||
return BOM + [headers, ...rows].map(r => r.map(q).join(sep)).join('\r\n');
|
||||
}
|
||||
|
||||
function platsToXLS(plats) {
|
||||
const esc = v => String(v ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
const cell = (v, t = 'String') => `<Cell><Data ss:Type="${t}">${esc(v)}</Data></Cell>`;
|
||||
const mkRow = cells => ` <Row>${cells.join('')}</Row>`;
|
||||
const header = mkRow(['ID','Nom','URL','Domiciliation','Fiscalité','Taux fiscal local (%)','Créé le'].map(h => cell(h)));
|
||||
const dataRows = plats.map(p => mkRow([
|
||||
cell(p.id, 'Number'), cell(p.nom), cell(p.url || ''),
|
||||
cell(countryLabel(p.domiciliation)),
|
||||
cell(FISCALITE_LABELS[p.fiscalite] ?? p.fiscalite ?? ''),
|
||||
cell(p.taux_fiscalite_locale ?? ''),
|
||||
cell(p.created_at ? new Date(p.created_at).toLocaleDateString('fr-FR') : ''),
|
||||
])).join('\n');
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<?mso-application progid="Excel.Sheet"?>
|
||||
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
|
||||
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
|
||||
<Styles><Style ss:ID="h"><Font ss:Bold="1"/></Style></Styles>
|
||||
<Worksheet ss:Name="Plateformes">
|
||||
<Table>
|
||||
${header}
|
||||
${dataRows}
|
||||
</Table>
|
||||
</Worksheet>
|
||||
</Workbook>`;
|
||||
}
|
||||
|
||||
/* ── Icônes utilitaires ──────────────────────────────────────── */
|
||||
function IconExport() {
|
||||
return <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>;
|
||||
}
|
||||
function IconCSV() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="10" y1="9" x2="14" y2="9"/></svg>;
|
||||
}
|
||||
function IconXLS() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l2 2 4-4"/></svg>;
|
||||
}
|
||||
function IconJSON() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M8 13h1.5a1.5 1.5 0 0 1 0 3H8v-3z"/><path d="M14 13h2v1.5a1.5 1.5 0 0 1-3 0V13z"/></svg>;
|
||||
}
|
||||
function IconImport() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 14 12 9 17 14"/><line x1="12" y1="9" x2="12" y2="21"/></svg>;
|
||||
}
|
||||
|
||||
/* ── ExportDropdown ──────────────────────────────────────────── */
|
||||
function ExportDropdown({ disabled, onCSV, onXLS, onJSON, title = 'Exporter' }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
const choose = (fn) => { setOpen(false); fn(); };
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative' }}>
|
||||
<button type="button" className="icon-btn" disabled={disabled}
|
||||
onClick={() => setOpen(o => !o)} title={title}
|
||||
aria-haspopup="menu" aria-expanded={open}>
|
||||
<IconExport />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="export-dropdown" role="menu">
|
||||
<button role="menuitem" onClick={() => choose(onCSV)}>
|
||||
<IconCSV /><span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
|
||||
</button>
|
||||
<button role="menuitem" onClick={() => choose(onXLS)}>
|
||||
<IconXLS /><span><strong>Format Excel</strong><small>Fichier .xls natif Microsoft</small></span>
|
||||
</button>
|
||||
{onJSON && (
|
||||
<button role="menuitem" onClick={() => choose(onJSON)}>
|
||||
<IconJSON /><span><strong>Format JSON</strong><small>Données structurées</small></span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Helpers PFU ─────────────────────────────────────────────── */
|
||||
|
||||
|
||||
|
||||
|
||||
function PlatDetailPanel({ plat, onEdit }) {
|
||||
const navigate = useNavigate();
|
||||
const [platCatsInv, setPlatCatsInv] = useState([]);
|
||||
const [platSectsInv, setPlatSectsInv] = useState([]);
|
||||
useEffect(() => {
|
||||
if (!plat) { setPlatCatsInv([]); setPlatSectsInv([]); return; }
|
||||
Promise.all([
|
||||
api.get(`/plateformes/${plat.id}/categories-inv`).catch(() => []),
|
||||
api.get(`/plateformes/${plat.id}/secteurs-inv`).catch(() => []),
|
||||
]).then(([cats, sects]) => { setPlatCatsInv(cats); setPlatSectsInv(sects); });
|
||||
}, [plat?.id]);
|
||||
|
||||
if (!plat) return (
|
||||
<div className="dr-detail dr-detail-empty">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.25, marginBottom: 8 }}>
|
||||
<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/>
|
||||
<line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
|
||||
</svg>
|
||||
<span>Sélectionnez une plateforme</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const inv = platInvestisseur(plat);
|
||||
const fields = [
|
||||
{ label: 'Plateforme', value: plat.nom },
|
||||
plat.url && {
|
||||
label: 'Site web',
|
||||
value: <a href={plat.url} target="_blank" rel="noreferrer"
|
||||
style={{ wordBreak: 'break-all', color: 'var(--primary)' }}>{plat.url}</a>,
|
||||
},
|
||||
{ label: 'Détenteur', value: inv ? memberLabel(inv) : '—' },
|
||||
plat.date_ouverture && { label: "Date d'ouverture", value: fmtDate(plat.date_ouverture) },
|
||||
{ label: 'Domiciliation', value: plat.domiciliation ? <span style={{ display:'inline-flex', alignItems:'center', gap:5 }}><FlagIcon code={plat.domiciliation} size={16} />{countryLabel(plat.domiciliation)}</span> : '—' },
|
||||
{ label: 'Fiscalité', value: fmtFiscalite(plat) },
|
||||
plat.domiciliation === 'FR' && {
|
||||
label: 'Déclaration 2561',
|
||||
value: (plat.type_produit_fiscal ?? '2TT') === '2TR'
|
||||
? 'Case 2TR — Produits de placement à revenu fixe'
|
||||
: 'Case 2TT — Produits des minibons et prêts participatifs',
|
||||
},
|
||||
{ label: 'Méthode de remboursement', value: METHODE_REMB_LABELS[plat.methode_remboursement] ?? '—' },
|
||||
platCatsInv.length > 0 && {
|
||||
label: "Catégories d'investissement",
|
||||
value: <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>{platCatsInv.map(c => <span key={c.id} className="chip-cat">{c.nom}</span>)}</div>,
|
||||
},
|
||||
platSectsInv.length > 0 && {
|
||||
label: "Secteurs d'investissement",
|
||||
value: <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>{platSectsInv.map(s => <span key={s.id} className="chip-sect">{s.nom}</span>)}</div>,
|
||||
},
|
||||
plat.type_pret_defaut && { label: 'Type de prêt (défaut)', value: { in_fine: 'In fine', amortissable: 'Amortissable', differe: 'Différé' }[plat.type_pret_defaut] ?? plat.type_pret_defaut },
|
||||
plat.freq_interets_defaut && { label: 'Périodicité (défaut)', value: { mensuel: 'Mensuelle', trimestriel: 'Trimestrielle', in_fine: 'In fine' }[plat.freq_interets_defaut] ?? plat.freq_interets_defaut },
|
||||
{
|
||||
label: 'Investissements',
|
||||
value: plat.nb_investissements != null
|
||||
? `${plat.nb_investissements} investissement${plat.nb_investissements !== 1 ? 's' : ''}`
|
||||
: '—',
|
||||
},
|
||||
plat.notes && { label: 'Notes', value: plat.notes },
|
||||
].filter(Boolean);
|
||||
|
||||
const logo = logoUrl(plat.logo_filename);
|
||||
|
||||
return (
|
||||
<div className="dr-detail">
|
||||
{logo && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '12px 0 4px' }}>
|
||||
<img src={logo} alt={`Logo ${plat.nom}`} className="logo-plateforme"
|
||||
style={{ maxHeight: 56, maxWidth: 160, objectFit: 'contain' }}
|
||||
onError={e => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="dr-detail-title">Détail de la plateforme</div>
|
||||
<div className="dr-detail-fields">
|
||||
{fields.map(f => (
|
||||
<div className="dr-detail-field" key={f.label}>
|
||||
<span className="dr-detail-label">{f.label}</span>
|
||||
<span className="dr-detail-value">{f.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="dr-detail-footer">
|
||||
{plat.referentiel_id && (
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={() => navigate(`/referentiel/${plat.referentiel_id}`)}
|
||||
style={{ marginBottom: 8, width: '100%', fontSize: 13 }}
|
||||
>
|
||||
Voir le profil de la plateforme
|
||||
</button>
|
||||
)}
|
||||
<button className="dr-detail-edit-btn" onClick={() => onEdit(plat)}>
|
||||
Modifier la plateforme
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Panneau de détail PFU ───────────────────────────────────── */
|
||||
|
||||
|
||||
export default function PlateformesSection() {
|
||||
// ── State ──────────────────────────────────────────────────────
|
||||
const [plats, setPlats] = useState([]);
|
||||
const [investisseurs, setInvestisseurs] = useState([]);
|
||||
const [referentiel, setReferentiel] = useState([]);
|
||||
const [newPlat, setNewPlat] = useState(EMPTY_PLAT);
|
||||
const [newPlatLogoFile, setNewPlatLogoFile] = useState(null);
|
||||
const [newPlatLogoPreview, setNewPlatLogoPreview] = useState(null);
|
||||
const [editPlat, setEditPlat] = useState(null);
|
||||
const [editPlatLogoFile, setEditPlatLogoFile] = useState(null);
|
||||
const [editPlatLogoPreview, setEditPlatLogoPreview] = useState(null);
|
||||
const [selectedPlat, setSelectedPlat] = useState(null);
|
||||
const [showNewPlat, setShowNewPlat] = useState(false);
|
||||
const [platOpenMenu, setPlatOpenMenu] = useState(null); // { plat, x, y }
|
||||
const [platExporting, setPlatExporting] = useState(false);
|
||||
const [platImportResult, setPlatImportResult] = useState(null);
|
||||
// Catégories d'investissement (globales + privées)
|
||||
const [categoriesInv, setCategoriesInv] = useState([]);
|
||||
const [selectedCatInv, setSelectedCatInv] = useState(null);
|
||||
const [editingCatInv, setEditingCatInv] = useState(null); // id en cours d'édition
|
||||
const [editingNomCatInv, setEditingNomCatInv] = useState('');
|
||||
const [newCatInvNom, setNewCatInvNom] = useState('');
|
||||
const [showNewCatInv, setShowNewCatInv] = useState(false);
|
||||
|
||||
// Secteurs d'investissement (globaux + privés)
|
||||
const [secteursInv, setSecteursInv] = useState([]);
|
||||
const [selectedSectInv, setSelectedSectInv] = useState(null);
|
||||
const [editingSectInv, setEditingSectInv] = useState(null);
|
||||
const [editingNomSectInv, setEditingNomSectInv] = useState('');
|
||||
const [newSectInvNom, setNewSectInvNom] = useState('');
|
||||
const [showNewSectInv, setShowNewSectInv] = useState(false);
|
||||
|
||||
// PFU
|
||||
const [showPfoDetail, setShowPfoDetail] = useState(false);
|
||||
|
||||
|
||||
const [err, setErr] = useState(null);
|
||||
const [msg, setMsg] = useState(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||
const platImportRef = useRef(null);
|
||||
|
||||
const load = async () => {
|
||||
const [p, invs, ref, catInv, sectInv] = await Promise.all([
|
||||
api.get('/plateformes'),
|
||||
api.get('/investisseurs'),
|
||||
api.get('/plateformes/referentiel-list'),
|
||||
api.get('/categories-inv'),
|
||||
api.get('/secteurs-inv'),
|
||||
]);
|
||||
setPlats(p);
|
||||
setInvestisseurs(invs);
|
||||
setReferentiel(ref);
|
||||
setCategoriesInv(catInv);
|
||||
setSecteursInv(sectInv);
|
||||
setSelectedPlat(prev => prev ? (p.find(x => x.id === prev.id) ?? null) : null);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line
|
||||
|
||||
useEffect(() => {
|
||||
if (plats.length === 0) return;
|
||||
setSelectedPlat(prev => prev ? prev : plats[0]);
|
||||
}, [plats]); // eslint-disable-line
|
||||
|
||||
const handleLogoFile = (file, setFile, setPreview) => {
|
||||
if (!file) { setFile(null); setPreview(null); return; }
|
||||
setFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => setPreview(ev.target.result);
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
/* ── Catégories d'investissement (privées) ──────────────────────── */
|
||||
const saveCatInv = async (nom) => {
|
||||
if (!nom.trim()) return;
|
||||
try {
|
||||
const row = await api.post('/categories-inv', { nom: nom.trim() });
|
||||
setCategoriesInv(prev => [...prev, row].sort((a, b) =>
|
||||
b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
|
||||
setShowNewCatInv(false); setNewCatInvNom(''); setErr(null);
|
||||
} catch (e) { setErr(e.message || 'Erreur'); }
|
||||
};
|
||||
const renameCatInv = async (id, nom) => {
|
||||
if (!nom.trim()) return;
|
||||
try {
|
||||
await api.put(`/categories-inv/${id}`, { nom: nom.trim() });
|
||||
setCategoriesInv(prev => prev.map(c => c.id === id ? { ...c, nom: nom.trim() } : c));
|
||||
setEditingCatInv(null);
|
||||
} catch (e) { setErr(e.message || 'Erreur'); }
|
||||
};
|
||||
const delCatInv = async (id) => {
|
||||
try {
|
||||
await api.del(`/categories-inv/${id}`);
|
||||
setCategoriesInv(prev => prev.filter(c => c.id !== id));
|
||||
if (selectedCatInv?.id === id) setSelectedCatInv(null);
|
||||
} catch (e) { setErr(e.message || 'Erreur'); }
|
||||
};
|
||||
|
||||
/* ── Secteurs d'investissement (privés) ──────────────────────── */
|
||||
const saveSectInv = async (nom) => {
|
||||
if (!nom.trim()) return;
|
||||
try {
|
||||
const row = await api.post('/secteurs-inv', { nom: nom.trim() });
|
||||
setSecteursInv(prev => [...prev, row].sort((a, b) =>
|
||||
b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
|
||||
setShowNewSectInv(false); setNewSectInvNom(''); setErr(null);
|
||||
} catch (e) { setErr(e.message || 'Erreur'); }
|
||||
};
|
||||
const renameSectInv = async (id, nom) => {
|
||||
if (!nom.trim()) return;
|
||||
try {
|
||||
await api.put(`/secteurs-inv/${id}`, { nom: nom.trim() });
|
||||
setSecteursInv(prev => prev.map(s => s.id === id ? { ...s, nom: nom.trim() } : s));
|
||||
setEditingSectInv(null);
|
||||
} catch (e) { setErr(e.message || 'Erreur'); }
|
||||
};
|
||||
const delSectInv = async (id) => {
|
||||
try {
|
||||
await api.del(`/secteurs-inv/${id}`);
|
||||
setSecteursInv(prev => prev.filter(s => s.id !== id));
|
||||
if (selectedSectInv?.id === id) setSelectedSectInv(null);
|
||||
} catch (e) { setErr(e.message || 'Erreur'); }
|
||||
};
|
||||
|
||||
/** Upload le logo vers le serveur après que la plateforme a été créée/sauvée */
|
||||
const uploadLogo = async (platId, file) => {
|
||||
if (!file) return null;
|
||||
const fd = new FormData();
|
||||
fd.append('logo', file);
|
||||
return api.upload(`/plateformes/${platId}/logo`, fd);
|
||||
};
|
||||
|
||||
/* ── Plateformes ─────────────────────────────────────────────── */
|
||||
const addPlat = async (e) => {
|
||||
e.preventDefault(); setErr(null); setMsg(null);
|
||||
try {
|
||||
const created = await api.post('/plateformes', {
|
||||
...newPlat,
|
||||
taux_fiscalite_locale: newPlat.fiscalite === 'avec_fiscalite_locale' && newPlat.taux_fiscalite_locale !== ''
|
||||
? Number(newPlat.taux_fiscalite_locale) : null,
|
||||
methode_remboursement: newPlat.methode_remboursement || 'portefeuille',
|
||||
investisseur_id: newPlat.investisseur_id ? Number(newPlat.investisseur_id) : null,
|
||||
date_ouverture: newPlat.date_ouverture || null,
|
||||
type_pret_defaut: newPlat.type_pret_defaut || null,
|
||||
freq_interets_defaut: newPlat.freq_interets_defaut || null,
|
||||
referentiel_id: newPlat.referentiel_id ? Number(newPlat.referentiel_id) : null,
|
||||
});
|
||||
if (newPlatLogoFile) await uploadLogo(created.id, newPlatLogoFile);
|
||||
setNewPlat(EMPTY_PLAT);
|
||||
setNewPlatLogoFile(null);
|
||||
setNewPlatLogoPreview(null);
|
||||
setShowNewPlat(false);
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
const delPlat = (id) => {
|
||||
setConfirmDelete({
|
||||
message: 'Supprimer cette plateforme ?',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await api.del(`/plateformes/${id}`);
|
||||
setSelectedPlat(null);
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setConfirmDelete(null); }
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ── Export/Import ZIP plateformes ─────────────────────────────────────
|
||||
const handlePlatExportAll = async () => {
|
||||
try {
|
||||
setPlatExporting(true);
|
||||
const blob = await api.blob('/plateformes/export');
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `plateformes-${new Date().toISOString().slice(0, 10)}.zip`;
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) { setPlatImportResult({ ok: false, msg: e.message }); }
|
||||
finally { setPlatExporting(false); }
|
||||
};
|
||||
|
||||
const handlePlatExportOne = async (plat) => {
|
||||
try {
|
||||
const blob = await api.blob(`/plateformes/${plat.id}/export`);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${plat.nom.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${new Date().toISOString().slice(0, 10)}.zip`;
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) { setPlatImportResult({ ok: false, msg: e.message }); }
|
||||
};
|
||||
|
||||
const handlePlatImportZip = async (file) => {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const r = await api.upload('/plateformes/import-zip', fd);
|
||||
setPlatImportResult({ ok: true, msg: `Import terminé : ${r.created} créée(s), ${r.updated} mise(s) à jour sur ${r.total} entrée(s).` });
|
||||
load();
|
||||
} catch (e) { setPlatImportResult({ ok: false, msg: e.message }); }
|
||||
};
|
||||
|
||||
const openEditPlat = async (p) => {
|
||||
setEditPlatLogoFile(null);
|
||||
setEditPlatLogoPreview(null);
|
||||
// Charger les associations catégories/secteurs d'investissement de la plateforme
|
||||
const [platCatsInv, platSectsInv] = await Promise.all([
|
||||
api.get(`/plateformes/${p.id}/categories-inv`).catch(() => []),
|
||||
api.get(`/plateformes/${p.id}/secteurs-inv`).catch(() => []),
|
||||
]);
|
||||
setEditPlat({
|
||||
id: p.id, nom: p.nom, url: p.url || '',
|
||||
categories_inv_ids: platCatsInv.map(c => c.id),
|
||||
secteurs_inv_ids: platSectsInv.map(s => s.id),
|
||||
inherited_cat_ids: platCatsInv.filter(c => c.is_inherited).map(c => c.id),
|
||||
inherited_sect_ids: platSectsInv.filter(s => s.is_inherited).map(s => s.id),
|
||||
domiciliation: p.domiciliation || 'france',
|
||||
fiscalite: p.fiscalite || 'flat_tax',
|
||||
taux_fiscalite_locale: p.taux_fiscalite_locale ?? '',
|
||||
type_produit_fiscal: p.type_produit_fiscal || '2TT',
|
||||
methode_remboursement: p.methode_remboursement || 'portefeuille',
|
||||
investisseur_id: p.investisseur_id ?? null,
|
||||
date_ouverture: p.date_ouverture || '',
|
||||
logo_filename: p.logo_filename || null,
|
||||
type_pret_defaut: p.type_pret_defaut || '',
|
||||
freq_interets_defaut: p.freq_interets_defaut || '',
|
||||
referentiel_id: p.referentiel_id ?? null,
|
||||
referentiel_nom: p.referentiel_nom ?? null,
|
||||
overridden_fields: p.overridden_fields ?? [],
|
||||
});
|
||||
};
|
||||
|
||||
const saveEditPlat = async (e) => {
|
||||
e.preventDefault(); setErr(null); setMsg(null);
|
||||
try {
|
||||
await api.put(`/plateformes/${editPlat.id}`, {
|
||||
nom: editPlat.nom, url: editPlat.url || '',
|
||||
domiciliation: editPlat.domiciliation,
|
||||
fiscalite: editPlat.fiscalite,
|
||||
taux_fiscalite_locale: editPlat.fiscalite === 'avec_fiscalite_locale' && editPlat.taux_fiscalite_locale !== ''
|
||||
? Number(editPlat.taux_fiscalite_locale) : null,
|
||||
type_produit_fiscal: editPlat.type_produit_fiscal || '2TT',
|
||||
methode_remboursement: editPlat.methode_remboursement || 'portefeuille',
|
||||
investisseur_id: editPlat.investisseur_id ? Number(editPlat.investisseur_id) : null,
|
||||
date_ouverture: editPlat.date_ouverture || null,
|
||||
type_pret_defaut: editPlat.type_pret_defaut || null,
|
||||
freq_interets_defaut: editPlat.freq_interets_defaut || null,
|
||||
});
|
||||
if (editPlatLogoFile) await uploadLogo(editPlat.id, editPlatLogoFile);
|
||||
// Sauvegarder les associations catégories/secteurs d'investissement
|
||||
await Promise.all([
|
||||
api.put(`/plateformes/${editPlat.id}/categories-inv`, { ids: editPlat.categories_inv_ids || [] }),
|
||||
api.put(`/plateformes/${editPlat.id}/secteurs-inv`, { ids: editPlat.secteurs_inv_ids || [] }),
|
||||
]);
|
||||
setMsg('Plateforme mise à jour.');
|
||||
setTimeout(() => setMsg(null), 3000);
|
||||
setEditPlatLogoFile(null);
|
||||
setEditPlatLogoPreview(null);
|
||||
setEditPlat(null);
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
const delLogoPlat = async (id) => {
|
||||
try {
|
||||
await api.del(`/plateformes/${id}/logo`);
|
||||
setEditPlat(ep => ep ? { ...ep, logo_filename: null } : ep);
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
/* ── PFU CRUD ────────────────────────────────────────────────── */
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
|
||||
{msg && <div className="success-msg" style={{ marginBottom: 12 }}>{msg}</div>}
|
||||
<div className="dr-mouvements-layout">
|
||||
|
||||
{/* Colonne gauche — liste */}
|
||||
<div className="dr-mouvements-list">
|
||||
<div className="card" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0 }}>Mes plateformes</h3>
|
||||
<p className="text-muted" style={{ margin: '4px 0 0', fontSize: 'var(--fs-sm)' }}>
|
||||
Plateformes de crowdlending que vous utilisez.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<ExportDropdown
|
||||
disabled={plats.length === 0}
|
||||
onCSV={() => dlBlob(platsToCSV(plats), 'plateformes.csv', 'text/csv;charset=utf-8')}
|
||||
onXLS={() => dlBlob(platsToXLS(plats), 'plateformes.xls', 'application/vnd.ms-excel')}
|
||||
/>
|
||||
<button onClick={handlePlatExportAll} disabled={platExporting || plats.length === 0}
|
||||
title="Exporter toutes les plateformes en ZIP"
|
||||
style={{ padding: '7px 12px', borderRadius: 6, border: '1px solid var(--border)', fontSize: 13, fontWeight: 600, cursor: 'pointer', background: 'var(--surface-2)', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
{platExporting ? '…' : 'Export ZIP'}
|
||||
</button>
|
||||
<button onClick={() => platImportRef.current?.click()}
|
||||
title="Importer un fichier ZIP de plateformes"
|
||||
style={{ padding: '7px 12px', borderRadius: 6, border: '1px solid var(--border)', fontSize: 13, fontWeight: 600, cursor: 'pointer', background: 'var(--surface-2)', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
Import ZIP
|
||||
</button>
|
||||
<input ref={platImportRef} type="file" accept=".zip" style={{ display: 'none' }}
|
||||
onChange={e => { const f = e.target.files[0]; e.target.value = ''; if (f) handlePlatImportZip(f); }} />
|
||||
<button className="primary" type="button"
|
||||
onClick={() => { setNewPlat(EMPTY_PLAT); setErr(null); setShowNewPlat(true); }}>
|
||||
+ Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{platImportResult && <ResultBanner result={platImportResult} onDismiss={() => setPlatImportResult(null)} style={{ marginBottom: 12 }} />}
|
||||
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
|
||||
<th style={{ padding: '10px 8px', width: 40 }}></th>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'left' }}>Nom</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Domiciliation</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Catégories</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Secteurs</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Détenteur</th>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'center', width: 60 }}>Invest.</th>
|
||||
<th style={{ padding: '10px 8px', width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plats.length === 0 && (
|
||||
<tr><td colSpan={7} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune plateforme</td></tr>
|
||||
)}
|
||||
{plats.map((p, i) => {
|
||||
const imgSrc = p.icone_filename ? logoUrl(p.icone_filename) : p.logo_filename ? logoUrl(p.logo_filename) : null;
|
||||
const inv = platInvestisseur(p);
|
||||
const cats = p.categories_inv || [];
|
||||
const sects = p.secteurs_inv || [];
|
||||
return (
|
||||
<tr key={p.id}
|
||||
onClick={() => setSelectedPlat(selectedPlat?.id === p.id ? null : p)}
|
||||
style={{
|
||||
borderBottom: i < plats.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
cursor: 'pointer',
|
||||
background: selectedPlat?.id === p.id ? 'var(--surface-2)' : 'none',
|
||||
}}
|
||||
onMouseEnter={e => { if (selectedPlat?.id !== p.id) e.currentTarget.style.background = 'var(--surface-2)'; }}
|
||||
onMouseLeave={e => { if (selectedPlat?.id !== p.id) e.currentTarget.style.background = 'none'; }}>
|
||||
<td style={{ padding: '8px 8px 8px 16px', width: 40 }}>
|
||||
{imgSrc
|
||||
? <img src={imgSrc} alt="" style={{ width: 32, height: 32, objectFit: 'contain', borderRadius: 4, display: 'block' }} />
|
||||
: <div style={{ width: 32, height: 32, borderRadius: 4, background: 'var(--surface-2)', border: '1px dashed var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-muted)', fontSize: 16 }}>×</div>
|
||||
}
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px' }}>
|
||||
<div style={{ fontWeight: 600 }}>{p.nom}</div>
|
||||
</td>
|
||||
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}>
|
||||
{p.domiciliation
|
||||
? <span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||
<FlagIcon code={p.domiciliation} size={15} />{countryLabel(p.domiciliation)}
|
||||
</span>
|
||||
: '—'}
|
||||
</td>
|
||||
<td style={{ padding: '10px 8px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{cats.slice(0, 2).map(c => <span key={c.id} className="chip-cat" style={{ fontSize: 11 }}>{c.nom}</span>)}
|
||||
{cats.length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{cats.length - 2}</span>}
|
||||
{cats.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '10px 8px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{sects.slice(0, 2).map(s => <span key={s.id} className="chip-sect" style={{ fontSize: 11 }}>{s.nom}</span>)}
|
||||
{sects.length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{sects.length - 2}</span>}
|
||||
{sects.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '10px 8px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{inv ? memberLabel(inv) : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px', textAlign: 'center', fontWeight: 600,
|
||||
color: (p.nb_investissements ?? 0) > 0 ? 'var(--text)' : 'var(--text-muted)' }}>
|
||||
{p.nb_investissements ?? 0}
|
||||
</td>
|
||||
<td style={{ padding: '10px 8px', textAlign: 'right' }}>
|
||||
<button
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 4, fontSize: 18, color: 'var(--text-muted)', lineHeight: 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
onClick={e => { e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); setPlatOpenMenu({ plat: p, x: rect.right, y: rect.bottom }); }}
|
||||
>⋮</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colonne droite — détail */}
|
||||
<div className="dr-mouvements-detail">
|
||||
<PlatDetailPanel
|
||||
plat={selectedPlat}
|
||||
onEdit={openEditPlat}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu ⋮ plateformes */}
|
||||
{platOpenMenu && (
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setPlatOpenMenu(null)} />
|
||||
<div style={{ position: 'fixed', left: platOpenMenu.x, top: platOpenMenu.y,
|
||||
transform: 'translateX(-100%) translateY(4px)', zIndex: 300,
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)', padding: '4px 0', minWidth: 170 }}>
|
||||
{[
|
||||
{ icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>, label: 'Modifier',
|
||||
onClick: () => { const p = platOpenMenu.plat; setPlatOpenMenu(null); openEditPlat(p); } },
|
||||
{ icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>, label: 'Exporter',
|
||||
onClick: () => { const p = platOpenMenu.plat; setPlatOpenMenu(null); handlePlatExportOne(p); } },
|
||||
{ icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>, label: 'Supprimer',
|
||||
onClick: () => { const p = platOpenMenu.plat; setPlatOpenMenu(null); delPlat(p.id); }, color: 'var(--danger)' },
|
||||
].map(({ icon, label, onClick, color }) => (
|
||||
<button key={label}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px',
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: 'var(--fs-sm)', color: color || 'var(--text)', textAlign: 'left' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
onClick={onClick}>
|
||||
<span style={{ opacity: 0.7, flexShrink: 0, display: 'flex' }}>{icon}</span>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<ConfirmModal
|
||||
open={!!confirmDelete}
|
||||
title="Supprimer la plateforme"
|
||||
message={confirmDelete?.message}
|
||||
onConfirm={confirmDelete?.onConfirm}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../../api.js';
|
||||
|
||||
export default function SecteursInvSection() {
|
||||
const [secteursInv, setSecteursInv] = useState([]);
|
||||
const [err, setErr] = useState(null);
|
||||
const [selectedSectInv, setSelectedSectInv] = useState(null);
|
||||
const [editingSectInv, setEditingSectInv] = useState(null);
|
||||
const [editingNomSectInv, setEditingNomSectInv] = useState('');
|
||||
const [newSectInvNom, setNewSectInvNom] = useState('');
|
||||
const [showNewSectInv, setShowNewSectInv] = useState(false);
|
||||
const [sectGlobalOpen, setSectGlobalOpen] = useState(false);
|
||||
const [sectPrivateOpen, setSectPrivateOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/secteurs-inv').then(data => {
|
||||
setSecteursInv(data.sort((a, b) => b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const saveSectInv = async (nom) => {
|
||||
if (!nom.trim()) return;
|
||||
try {
|
||||
const row = await api.post('/secteurs-inv', { nom: nom.trim() });
|
||||
setSecteursInv(prev => [...prev, row].sort((a, b) =>
|
||||
b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
|
||||
setShowNewSectInv(false); setNewSectInvNom(''); setErr(null);
|
||||
} catch (e) { setErr(e.message || 'Erreur'); }
|
||||
};
|
||||
const renameSectInv = async (id, nom) => {
|
||||
if (!nom.trim()) return;
|
||||
try {
|
||||
await api.put(`/secteurs-inv/${id}`, { nom: nom.trim() });
|
||||
setSecteursInv(prev => prev.map(s => s.id === id ? { ...s, nom: nom.trim() } : s));
|
||||
setEditingSectInv(null);
|
||||
} catch (e) { setErr(e.message || 'Erreur'); }
|
||||
};
|
||||
const delSectInv = async (id) => {
|
||||
try {
|
||||
await api.del(`/secteurs-inv/${id}`);
|
||||
setSecteursInv(prev => prev.filter(s => s.id !== id));
|
||||
if (selectedSectInv?.id === id) setSelectedSectInv(null);
|
||||
} catch (e) { setErr(e.message || 'Erreur'); }
|
||||
};
|
||||
|
||||
const globalSects = secteursInv.filter(s => s.is_global);
|
||||
const privateSects = secteursInv.filter(s => !s.is_global);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<h3 style={{ margin: 0 }}>Mes secteurs d'investissement</h3>
|
||||
</div>
|
||||
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
|
||||
|
||||
{/* Accordéon — Secteurs globalement définis */}
|
||||
<div className="card" style={{ marginBottom: 10 }}>
|
||||
<button type="button"
|
||||
onClick={() => setSectGlobalOpen(o => !o)}
|
||||
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--text)' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 'var(--fs-base)' }}>
|
||||
Secteurs globalement définis
|
||||
<span style={{ marginLeft: 8, fontWeight: 400, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
||||
({globalSects.length})
|
||||
</span>
|
||||
</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
|
||||
strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ transform: sectGlobalOpen ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
{sectGlobalOpen && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th className="num">Plateformes</th>
|
||||
<th className="num">Investissements</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{globalSects.length === 0 && (
|
||||
<tr><td colSpan={3} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucun secteur global</td></tr>
|
||||
)}
|
||||
{globalSects.map(s => (
|
||||
<tr key={s.id}>
|
||||
<td><span style={{ fontWeight: 600 }}>{s.nom}</span></td>
|
||||
<td className="num">{s.nb_plateformes > 0 ? s.nb_plateformes : <span className="text-muted">—</span>}</td>
|
||||
<td className="num">{s.nb_investissements > 0 ? s.nb_investissements : <span className="text-muted">—</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Accordéon — Mes propres secteurs */}
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<button type="button"
|
||||
onClick={() => setSectPrivateOpen(o => !o)}
|
||||
style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 8,
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--text)', textAlign: 'left' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 'var(--fs-base)' }}>
|
||||
Mes propres secteurs
|
||||
<span style={{ marginLeft: 8, fontWeight: 400, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
||||
({privateSects.length})
|
||||
</span>
|
||||
</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
|
||||
strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ transform: sectPrivateOpen ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
{sectPrivateOpen && (
|
||||
<button className="primary" type="button" style={{ marginLeft: 12, flexShrink: 0 }}
|
||||
onClick={() => { setSectPrivateOpen(true); setShowNewSectInv(true); setNewSectInvNom(''); setErr(null); }}>
|
||||
+ Ajouter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{sectPrivateOpen && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
{showNewSectInv && (
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<input autoFocus style={{ flex: 1 }} placeholder="Nom du secteur"
|
||||
value={newSectInvNom} onChange={e => setNewSectInvNom(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') saveSectInv(newSectInvNom); if (e.key === 'Escape') setShowNewSectInv(false); }} />
|
||||
<button className="primary" onClick={() => saveSectInv(newSectInvNom)}>Créer</button>
|
||||
<button onClick={() => setShowNewSectInv(false)}>Annuler</button>
|
||||
</div>
|
||||
)}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th className="num">Plateformes</th>
|
||||
<th className="num">Investissements</th>
|
||||
<th style={{ width: 80 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{privateSects.length === 0 && (
|
||||
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucun secteur personnel</td></tr>
|
||||
)}
|
||||
{privateSects.map(s => (
|
||||
<tr key={s.id}>
|
||||
<td>
|
||||
{editingSectInv === s.id ? (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input autoFocus style={{ flex: 1 }} value={editingNomSectInv}
|
||||
onChange={e => setEditingNomSectInv(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') renameSectInv(s.id, editingNomSectInv); if (e.key === 'Escape') setEditingSectInv(null); }} />
|
||||
<button className="primary" onClick={() => renameSectInv(s.id, editingNomSectInv)}>OK</button>
|
||||
<button onClick={() => setEditingSectInv(null)}>✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontWeight: 600 }}>{s.nom}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="num">{s.nb_plateformes > 0 ? s.nb_plateformes : <span className="text-muted">—</span>}</td>
|
||||
<td className="num">{s.nb_investissements > 0 ? s.nb_investissements : <span className="text-muted">—</span>}</td>
|
||||
<td>
|
||||
{editingSectInv !== s.id && (
|
||||
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
|
||||
<button style={{ fontSize: 12, padding: '2px 8px' }}
|
||||
onClick={() => { setEditingSectInv(s.id); setEditingNomSectInv(s.nom); setErr(null); }}>
|
||||
Renommer
|
||||
</button>
|
||||
<button style={{ fontSize: 12, padding: '2px 8px', color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
||||
onClick={() => setConfirmDelete({
|
||||
title: 'Supprimer le secteur',
|
||||
message: `Supprimer le secteur "${s.nom}" ? Il sera retiré de toutes les plateformes et investissements.`,
|
||||
confirmLabel: 'Supprimer',
|
||||
onConfirm: () => { delSectInv(s.id); setConfirmDelete(null); }
|
||||
})}>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user