Initial commit

This commit is contained in:
Olivier CROGUENNEC
2026-06-13 14:57:15 +02:00
commit 48ed7fe65e
209 changed files with 49979 additions and 0 deletions
@@ -0,0 +1,343 @@
import { useState, useEffect } from 'react';
import { useTheme } from '../../context/ThemeContext.jsx';
import { useUi } from '../../context/UiContext.jsx';
import { api } from '../../api.js';
/* ── Apparence — données ─────────────────────────────────────── */
const THEMES = [
{
mode: 'light', label: 'Clair', desc: 'Interface lumineuse',
preview: (
<svg viewBox="0 0 56 36" width="56" height="36" aria-hidden="true">
<rect width="56" height="36" rx="5" fill="#f0f4ff"/>
<rect x="2" y="2" width="12" height="32" rx="3" fill="#1e3a8a"/>
<rect x="16" y="2" width="38" height="8" rx="2" fill="#ffffff" opacity=".9"/>
<rect x="16" y="12" width="38" height="5" rx="2" fill="#c7d2e8"/>
<rect x="16" y="19" width="28" height="5" rx="2" fill="#c7d2e8"/>
<rect x="16" y="26" width="20" height="5" rx="2" fill="#c7d2e8"/>
</svg>
),
},
{
mode: 'dark', label: 'Sombre', desc: 'Interface nocturne',
preview: (
<svg viewBox="0 0 56 36" width="56" height="36" aria-hidden="true">
<rect width="56" height="36" rx="5" fill="#060e1f"/>
<rect x="2" y="2" width="12" height="32" rx="3" fill="#0d1629"/>
<rect x="16" y="2" width="38" height="8" rx="2" fill="#111c35" opacity=".9"/>
<rect x="16" y="12" width="38" height="5" rx="2" fill="#1e3a6a"/>
<rect x="16" y="19" width="28" height="5" rx="2" fill="#1e3a6a"/>
<rect x="16" y="26" width="20" height="5" rx="2" fill="#1e3a6a"/>
</svg>
),
},
{
mode: 'system', label: 'Système', desc: 'Suit les préférences OS',
preview: (
<svg viewBox="0 0 56 36" width="56" height="36" aria-hidden="true">
<defs>
<linearGradient id="split" x1="0" x2="1" y1="0" y2="0">
<stop offset="50%" stopColor="#f0f4ff"/>
<stop offset="50%" stopColor="#060e1f"/>
</linearGradient>
</defs>
<rect width="56" height="36" rx="5" fill="url(#split)"/>
<rect x="2" y="2" width="12" height="32" rx="3" fill="#1e3a8a"/>
<rect x="16" y="2" width="18" height="8" rx="2" fill="#ffffff" opacity=".9"/>
<rect x="36" y="2" width="18" height="8" rx="2" fill="#111c35" opacity=".9"/>
<rect x="16" y="12" width="18" height="5" rx="2" fill="#c7d2e8"/>
<rect x="36" y="12" width="18" height="5" rx="2" fill="#1e3a6a"/>
</svg>
),
},
];
const FONTS = [
{ scale: 'compact', label: 'Normal', desc: 'Interface compacte', sizes: { body: 12, table: 11 } },
{ scale: 'medium', label: 'Moyen', desc: 'Taille intermédiaire', sizes: { body: 13, table: 12 } },
{ scale: 'large', label: 'Grand', desc: 'Meilleure lisibilité', sizes: { body: 14, table: 13 } },
];
/* ── Icônes graphiques (couleurs) ────────────────────────────── */
const ICONS_BASE = '/api/icons-files/';
function AppIcon({ filename, size = 22 }) {
if (!filename) return null;
return (
<img
src={`${ICONS_BASE}${filename}`}
alt=""
width={size}
height={size}
className="app-lib-icon"
aria-hidden="true"
/>
);
}
/* ── Palette Material Design 2 ───────────────────────────────── */
const MD_PALETTE = {
'Rouge': { base: '#f44336', shades: { '50':'#ffebee','100':'#ffcdd2','200':'#ef9a9a','300':'#e57373','400':'#ef5350','500':'#f44336','600':'#e53935','700':'#d32f2f','800':'#c62828','900':'#b71c1c','A100':'#ff8a80','A200':'#ff5252','A400':'#ff1744','A700':'#d50000' } },
'Rose': { base: '#e91e63', shades: { '50':'#fce4ec','100':'#f8bbd0','200':'#f48fb1','300':'#f06292','400':'#ec407a','500':'#e91e63','600':'#d81b60','700':'#c2185b','800':'#ad1457','900':'#880e4f','A100':'#ff80ab','A200':'#ff4081','A400':'#f50057','A700':'#c51162' } },
'Violet': { base: '#9c27b0', shades: { '50':'#f3e5f5','100':'#e1bee7','200':'#ce93d8','300':'#ba68c8','400':'#ab47bc','500':'#9c27b0','600':'#8e24aa','700':'#7b1fa2','800':'#6a1b9a','900':'#4a148c','A100':'#ea80fc','A200':'#e040fb','A400':'#d500f9','A700':'#aa00ff' } },
'Violet foncé': { base: '#673ab7', shades: { '50':'#ede7f6','100':'#d1c4e9','200':'#b39ddb','300':'#9575cd','400':'#7e57c2','500':'#673ab7','600':'#5e35b1','700':'#512da8','800':'#4527a0','900':'#311b92','A100':'#b388ff','A200':'#7c4dff','A400':'#651fff','A700':'#6200ea' } },
'Indigo': { base: '#3f51b5', shades: { '50':'#e8eaf6','100':'#c5cae9','200':'#9fa8da','300':'#7986cb','400':'#5c6bc0','500':'#3f51b5','600':'#3949ab','700':'#303f9f','800':'#283593','900':'#1a237e','A100':'#8c9eff','A200':'#536dfe','A400':'#3d5afe','A700':'#304ffe' } },
'Bleu': { base: '#2196f3', shades: { '50':'#e3f2fd','100':'#bbdefb','200':'#90caf9','300':'#64b5f6','400':'#42a5f5','500':'#2196f3','600':'#1e88e5','700':'#1976d2','800':'#1565c0','900':'#0d47a1','A100':'#82b1ff','A200':'#448aff','A400':'#2979ff','A700':'#2962ff' } },
'Bleu clair': { base: '#03a9f4', shades: { '50':'#e1f5fe','100':'#b3e5fc','200':'#81d4fa','300':'#4fc3f7','400':'#29b6f6','500':'#03a9f4','600':'#039be5','700':'#0288d1','800':'#0277bd','900':'#01579b','A100':'#80d8ff','A200':'#40c4ff','A400':'#00b0ff','A700':'#0091ea' } },
'Cyan': { base: '#00bcd4', shades: { '50':'#e0f7fa','100':'#b2ebf2','200':'#80deea','300':'#4dd0e1','400':'#26c6da','500':'#00bcd4','600':'#00acc1','700':'#0097a7','800':'#00838f','900':'#006064','A100':'#84ffff','A200':'#18ffff','A400':'#00e5ff','A700':'#00b8d4' } },
'Sarcelle': { base: '#009688', shades: { '50':'#e0f2f1','100':'#b2dfdb','200':'#80cbc4','300':'#4db6ac','400':'#26a69a','500':'#009688','600':'#00897b','700':'#00796b','800':'#00695c','900':'#004d40','A100':'#a7ffeb','A200':'#64ffda','A400':'#1de9b6','A700':'#00bfa5' } },
'Vert': { base: '#4caf50', shades: { '50':'#e8f5e9','100':'#c8e6c9','200':'#a5d6a7','300':'#81c784','400':'#66bb6a','500':'#4caf50','600':'#43a047','700':'#388e3c','800':'#2e7d32','900':'#1b5e20','A100':'#b9f6ca','A200':'#69f0ae','A400':'#00e676','A700':'#00c853' } },
'Vert clair': { base: '#8bc34a', shades: { '50':'#f1f8e9','100':'#dcedc8','200':'#c5e1a5','300':'#aed581','400':'#9ccc65','500':'#8bc34a','600':'#7cb342','700':'#689f38','800':'#558b2f','900':'#33691e','A100':'#ccff90','A200':'#b2ff59','A400':'#76ff03','A700':'#64dd17' } },
'Citron vert': { base: '#cddc39', shades: { '50':'#f9fbe7','100':'#f0f4c3','200':'#e6ee9c','300':'#dce775','400':'#d4e157','500':'#cddc39','600':'#c0ca33','700':'#afb42b','800':'#9e9d24','900':'#827717','A100':'#f4ff81','A200':'#eeff41','A400':'#c6ff00','A700':'#aeea00' } },
'Jaune': { base: '#ffeb3b', shades: { '50':'#fffde7','100':'#fff9c4','200':'#fff59d','300':'#fff176','400':'#ffee58','500':'#ffeb3b','600':'#fdd835','700':'#fbc02d','800':'#f9a825','900':'#f57f17','A100':'#ffff8d','A200':'#ffff00','A400':'#ffea00','A700':'#ffd600' } },
'Ambre': { base: '#ffc107', shades: { '50':'#fff8e1','100':'#ffecb3','200':'#ffe082','300':'#ffd54f','400':'#ffca28','500':'#ffc107','600':'#ffb300','700':'#ffa000','800':'#ff8f00','900':'#ff6f00','A100':'#ffe57f','A200':'#ffd740','A400':'#ffc400','A700':'#ffab00' } },
'Orange': { base: '#ff9800', shades: { '50':'#fff3e0','100':'#ffe0b2','200':'#ffcc80','300':'#ffb74d','400':'#ffa726','500':'#ff9800','600':'#fb8c00','700':'#f57c00','800':'#ef6c00','900':'#e65100','A100':'#ffd180','A200':'#ffab40','A400':'#ff9100','A700':'#ff6d00' } },
'Orange foncé': { base: '#ff5722', shades: { '50':'#fbe9e7','100':'#ffccbc','200':'#ffab91','300':'#ff8a65','400':'#ff7043','500':'#ff5722','600':'#f4511e','700':'#e64a19','800':'#d84315','900':'#bf360c','A100':'#ff9e80','A200':'#ff6e40','A400':'#ff3d00','A700':'#dd2c00' } },
'Marron': { base: '#795548', shades: { '50':'#efebe9','100':'#d7ccc8','200':'#bcaaa4','300':'#a1887f','400':'#8d6e63','500':'#795548','600':'#6d4c41','700':'#5d4037','800':'#4e342e','900':'#3e2723' } },
'Gris': { base: '#9e9e9e', shades: { '50':'#fafafa','100':'#f5f5f5','200':'#eeeeee','300':'#e0e0e0','400':'#bdbdbd','500':'#9e9e9e','600':'#757575','700':'#616161','800':'#424242','900':'#212121' } },
'Gris bleu': { base: '#607d8b', shades: { '50':'#eceff1','100':'#cfd8dc','200':'#b0bec5','300':'#90a4ae','400':'#78909c','500':'#607d8b','600':'#546e7a','700':'#455a64','800':'#37474f','900':'#263238' } },
};
/* Retourne true si la couleur hex est claire (pour adapter la couleur du texte) */
function isLightColor(hex) {
const r = parseInt(hex.slice(1,3),16);
const g = parseInt(hex.slice(3,5),16);
const b = parseInt(hex.slice(5,7),16);
return (r * 299 + g * 587 + b * 114) / 1000 > 155;
}
/* Retrouve la famille et la teinte d'un hex donné */
function findInPalette(hex) {
const h = hex.toLowerCase();
for (const [family, { shades }] of Object.entries(MD_PALETTE)) {
for (const [shade, color] of Object.entries(shades)) {
if (color.toLowerCase() === h) return { family, shade };
}
}
return null;
}
/* Mélange une couleur hex avec du blanc (factor 0=original, 1=blanc) */
function lightenColor(hex, factor = 0.72) {
const r = parseInt(hex.slice(1,3),16);
const g = parseInt(hex.slice(3,5),16);
const b = parseInt(hex.slice(5,7),16);
const lr = Math.round(r + (255 - r) * factor);
const lg = Math.round(g + (255 - g) * factor);
const lb = Math.round(b + (255 - b) * factor);
return '#' + [lr,lg,lb].map(v => v.toString(16).padStart(2,'0')).join('');
}
/* ── Palettes suggérées ──────────────────────────────────────── */
const SUGGESTED_PALETTES = [
{ name: 'Classique', interets: '#2196f3', capital: '#4caf50', cashback: '#ffc107' },
{ name: 'Indigo & Teal', interets: '#3949ab', capital: '#009688', cashback: '#fb8c00' },
{ name: 'Nuit', interets: '#5c6bc0', capital: '#4dd0e1', cashback: '#ffca28' },
{ name: 'Nature', interets: '#43a047', capital: '#039be5', cashback: '#ff9800' },
{ name: 'Coucher de soleil', interets: '#ff5722', capital: '#1976d2', cashback: '#ffa000' },
{ name: 'Violet', interets: '#7e57c2', capital: '#26a69a', cashback: '#ffb300' },
{ name: 'Frais', interets: '#00acc1', capital: '#7cb342', cashback: '#ec407a' },
{ name: 'Contraste', interets: '#e53935', capital: '#1e88e5', cashback: '#43a047' },
{ name: 'Pastel', interets: '#29b6f6', capital: '#66bb6a', cashback: '#ffb74d' },
{ name: 'Moderne', interets: '#9c27b0', capital: '#00bcd4', cashback: '#ff9800' },
];
function PaletteSelector({ interets, capital, cashback, onSelect }) {
const isActive = (p) =>
p.interets === interets && p.capital === capital && p.cashback === cashback;
return (
<div style={{ marginBottom: 24 }}>
<p style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)', fontWeight: 600, color: 'var(--text)' }}>
Palettes suggérées
</p>
<div className="palette-grid">
{SUGGESTED_PALETTES.map((p) => (
<button
key={p.name}
type="button"
className={`palette-card${isActive(p) ? ' active' : ''}`}
onClick={() => onSelect(p)}
title={p.name}
>
<div className="palette-swatches">
<span className="palette-swatch" style={{ backgroundColor: p.interets }} />
<span className="palette-swatch" style={{ backgroundColor: p.capital }} />
<span className="palette-swatch" style={{ backgroundColor: p.cashback }} />
</div>
<span className="palette-name">{p.name}</span>
</button>
))}
</div>
</div>
);
}
function ChartColorPicker({ value, onChange }) {
const found = findInPalette(value);
const [selectedFamily, setSelectedFamily] = useState(found?.family || 'Bleu');
// Sync la famille quand value change depuis l'extérieur (sélection palette)
useEffect(() => {
const f = findInPalette(value);
if (f) setSelectedFamily(f.family);
}, [value]);
const familyData = MD_PALETTE[selectedFamily];
const shadeEntries = familyData ? Object.entries(familyData.shades) : [];
function handleFamilyChange(e) {
const fam = e.target.value;
setSelectedFamily(fam);
// Auto-select shade 500 (or first available) when changing family
const shades = MD_PALETTE[fam]?.shades || {};
const target = shades['500'] || Object.values(shades)[5] || Object.values(shades)[0];
if (target) onChange(target);
}
const textColor = isLightColor(value) ? '#212121' : '#ffffff';
return (
<div className="chart-color-picker">
<div className="color-family-select-row">
<span className="color-family-indicator" style={{ backgroundColor: familyData?.base || value }} />
<select value={selectedFamily} onChange={handleFamilyChange}>
{Object.keys(MD_PALETTE).map(name => (
<option key={name} value={name}>{name}</option>
))}
</select>
</div>
<div className="color-shade-grid">
{shadeEntries.map(([shade, hex]) => (
<button
key={shade}
type="button"
title={`${selectedFamily} ${shade}${hex}`}
className={`color-shade-btn${value.toLowerCase() === hex.toLowerCase() ? ' selected' : ''}`}
style={{ backgroundColor: hex }}
onClick={() => onChange(hex)}
>
<span className="color-shade-label" style={{ color: isLightColor(hex) ? '#000' : '#fff' }}>
{shade}
</span>
</button>
))}
</div>
<div className="color-preview-pair">
<div className="color-preview-bar" style={{ backgroundColor: value, color: textColor }}>
<span className="color-preview-label">Reçu</span>
<span className="color-preview-hex">{value.toUpperCase()}</span>
</div>
<div className="color-preview-bar" style={{ backgroundColor: lightenColor(value), color: isLightColor(lightenColor(value)) ? '#333' : '#fff' }}>
<span className="color-preview-label">Projeté</span>
<span className="color-preview-hex">{lightenColor(value).toUpperCase()}</span>
</div>
</div>
</div>
);
}
export default function AppearanceSection() {
const { mode, setMode } = useTheme();
const { fontScale, setFontScale, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback } = useUi();
const [libIcons, setLibIcons] = useState({});
useEffect(() => {
api.get('/icons').then(rows => {
const m = {};
rows.forEach(r => { m[r.name] = r.filename; });
setLibIcons(m);
}).catch(() => {});
}, []);
return (
<>
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Thème</h3>
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
Choisissez le thème visuel de l'application.
</p>
<div className="pref-options">
{THEMES.map((t) => (
<button key={t.mode} type="button"
className={`pref-option${mode === t.mode ? ' active' : ''}`}
onClick={() => setMode(t.mode)} aria-pressed={mode === t.mode}>
{t.preview}
<span style={{ fontWeight: 700, fontSize: 'var(--fs-sm)', marginTop: 4 }}>{t.label}</span>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>{t.desc}</span>
</button>
))}
</div>
</div>
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Taille du texte</h3>
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
Le niveau <strong>Grand</strong> est recommandé pour les personnes malvoyantes.
Les niveaux inférieurs permettent d'afficher plus de données à l'écran.
</p>
<div className="pref-options">
{FONTS.map((f) => (
<button key={f.scale} type="button"
className={`pref-option${fontScale === f.scale ? ' active' : ''}`}
onClick={() => setFontScale(f.scale)} aria-pressed={fontScale === f.scale}>
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4,
width: '100%', padding: '8px 0 4px',
borderBottom: '1px solid var(--border)', marginBottom: 4,
}}>
<span className="font-preview" style={{ fontSize: f.sizes.body }}>Aa — {f.label}</span>
<span style={{ fontSize: f.sizes.table, color: 'var(--text-muted)' }}>
Tableau {f.sizes.table}px · Corps {f.sizes.body}px
</span>
</div>
<span style={{ fontWeight: 700, fontSize: 'var(--fs-sm)', marginTop: 2 }}>{f.label}</span>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>{f.desc}</span>
</button>
))}
</div>
</div>
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Couleurs des graphiques</h3>
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
Choisissez une palette ci-dessous ou personnalisez chaque série individuellement.
</p>
<PaletteSelector
interets={chartInterets}
capital={chartCapital}
cashback={chartCashback}
onSelect={(p) => { setChartInterets(p.interets); setChartCapital(p.capital); setChartCashback(p.cashback); }}
/>
<div className="chart-color-row">
<div className="chart-color-item">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '1.1rem', fontWeight: 600 }}>
<AppIcon filename={libIcons.interets} size={33} />
Intérêts
</label>
<ChartColorPicker value={chartInterets} onChange={setChartInterets} />
</div>
<div className="chart-color-item">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '1.1rem', fontWeight: 600 }}>
<AppIcon filename={libIcons.capital} size={33} />
Capital
</label>
<ChartColorPicker value={chartCapital} onChange={setChartCapital} />
</div>
<div className="chart-color-item">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '1.1rem', fontWeight: 600 }}>
<AppIcon filename={libIcons.cashback} size={33} />
Cashback
</label>
<ChartColorPicker value={chartCashback} onChange={setChartCashback} />
</div>
</div>
</div>
</>
);
}
/* ── Icônes nav ──────────────────────────────────────────────── */
@@ -0,0 +1,197 @@
import { useState, useEffect } from 'react';
import { api } from '../../api.js';
export default function CategoriesInvSection() {
const [categoriesInv, setCategoriesInv] = useState([]);
const [err, setErr] = useState(null);
const [selectedCatInv, setSelectedCatInv] = useState(null);
const [editingCatInv, setEditingCatInv] = useState(null);
const [editingNomCatInv, setEditingNomCatInv] = useState('');
const [newCatInvNom, setNewCatInvNom] = useState('');
const [showNewCatInv, setShowNewCatInv] = useState(false);
const [catGlobalOpen, setCatGlobalOpen] = useState(false);
const [catPrivateOpen, setCatPrivateOpen] = useState(true);
useEffect(() => {
api.get('/categories-inv').then(data => {
setCategoriesInv(data.sort((a, b) => b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
}).catch(() => {});
}, []);
const saveCatInv = async (nom) => {
if (!nom.trim()) return;
try {
const row = await api.post('/categories-inv', { nom: nom.trim() });
setCategoriesInv(prev => [...prev, row].sort((a, b) =>
b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
setShowNewCatInv(false); setNewCatInvNom(''); setErr(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const renameCatInv = async (id, nom) => {
if (!nom.trim()) return;
try {
await api.put(`/categories-inv/${id}`, { nom: nom.trim() });
setCategoriesInv(prev => prev.map(c => c.id === id ? { ...c, nom: nom.trim() } : c));
setEditingCatInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const delCatInv = async (id) => {
try {
await api.del(`/categories-inv/${id}`);
setCategoriesInv(prev => prev.filter(c => c.id !== id));
if (selectedCatInv?.id === id) setSelectedCatInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const globalCats = categoriesInv.filter(c => c.is_global);
const privateCats = categoriesInv.filter(c => !c.is_global);
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<h3 style={{ margin: 0 }}>Mes catégories d'investissement</h3>
</div>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
{/* Accordéon — Catégories globalement définies */}
<div className="card" style={{ marginBottom: 10 }}>
<button type="button"
onClick={() => setCatGlobalOpen(o => !o)}
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--text)' }}>
<span style={{ fontWeight: 600, fontSize: 'var(--fs-base)' }}>
Catégories globalement définies
<span style={{ marginLeft: 8, fontWeight: 400, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
({globalCats.length})
</span>
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
style={{ transform: catGlobalOpen ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{catGlobalOpen && (
<div style={{ marginTop: 14 }}>
<table>
<thead>
<tr>
<th>Nom</th>
<th className="num">Plateformes</th>
<th className="num">Investissements</th>
</tr>
</thead>
<tbody>
{globalCats.length === 0 && (
<tr><td colSpan={3} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune catégorie globale</td></tr>
)}
{globalCats.map(c => (
<tr key={c.id}>
<td><span style={{ fontWeight: 600 }}>{c.nom}</span></td>
<td className="num">{c.nb_plateformes > 0 ? c.nb_plateformes : <span className="text-muted">—</span>}</td>
<td className="num">{c.nb_investissements > 0 ? c.nb_investissements : <span className="text-muted">—</span>}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Accordéon — Mes propres catégories */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<button type="button"
onClick={() => setCatPrivateOpen(o => !o)}
style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 8,
background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--text)', textAlign: 'left' }}>
<span style={{ fontWeight: 600, fontSize: 'var(--fs-base)' }}>
Mes propres catégories
<span style={{ marginLeft: 8, fontWeight: 400, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
({privateCats.length})
</span>
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
style={{ transform: catPrivateOpen ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{catPrivateOpen && (
<button className="primary" type="button" style={{ marginLeft: 12, flexShrink: 0 }}
onClick={() => { setCatPrivateOpen(true); setShowNewCatInv(true); setNewCatInvNom(''); setErr(null); }}>
+ Ajouter
</button>
)}
</div>
{catPrivateOpen && (
<div style={{ marginTop: 14 }}>
{showNewCatInv && (
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<input autoFocus style={{ flex: 1 }} placeholder="Nom de la catégorie"
value={newCatInvNom} onChange={e => setNewCatInvNom(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') saveCatInv(newCatInvNom); if (e.key === 'Escape') setShowNewCatInv(false); }} />
<button className="primary" onClick={() => saveCatInv(newCatInvNom)}>Créer</button>
<button onClick={() => setShowNewCatInv(false)}>Annuler</button>
</div>
)}
<table>
<thead>
<tr>
<th>Nom</th>
<th className="num">Plateformes</th>
<th className="num">Investissements</th>
<th style={{ width: 80 }}></th>
</tr>
</thead>
<tbody>
{privateCats.length === 0 && (
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune catégorie personnelle</td></tr>
)}
{privateCats.map(c => (
<tr key={c.id}>
<td>
{editingCatInv === c.id ? (
<div style={{ display: 'flex', gap: 6 }}>
<input autoFocus style={{ flex: 1 }} value={editingNomCatInv}
onChange={e => setEditingNomCatInv(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') renameCatInv(c.id, editingNomCatInv); if (e.key === 'Escape') setEditingCatInv(null); }} />
<button className="primary" onClick={() => renameCatInv(c.id, editingNomCatInv)}>OK</button>
<button onClick={() => setEditingCatInv(null)}>✕</button>
</div>
) : (
<span style={{ fontWeight: 600 }}>{c.nom}</span>
)}
</td>
<td className="num">{c.nb_plateformes > 0 ? c.nb_plateformes : <span className="text-muted">—</span>}</td>
<td className="num">{c.nb_investissements > 0 ? c.nb_investissements : <span className="text-muted">—</span>}</td>
<td>
{editingCatInv !== c.id && (
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
<button style={{ fontSize: 12, padding: '2px 8px' }}
onClick={() => { setEditingCatInv(c.id); setEditingNomCatInv(c.nom); setErr(null); }}>
Renommer
</button>
<button style={{ fontSize: 12, padding: '2px 8px', color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => setConfirmDelete({
title: 'Supprimer la catégorie',
message: `Supprimer la catégorie "${c.nom}" ? Elle sera retirée de toutes les plateformes et investissements.`,
confirmLabel: 'Supprimer',
onConfirm: () => { delCatInv(c.id); setConfirmDelete(null); }
})}>
Supprimer
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,250 @@
import { useState, useEffect, useCallback } from 'react';
import { api } from '../../api.js';
import { memberLabel } from '../../utils/format.js';
import ConfirmModal from '../../components/ConfirmModal.jsx';
import Modal from '../../components/Modal.jsx';
const EXONERATION_LABELS = {
aucune: 'Aucune',
pfnl_5ans: 'PFnl 5 ans',
};
const TYPE_COMPTE_LABELS = {
compte_courant: 'Compte courant',
pea_pme: 'PEA-PME',
};
const EMPTY_COMPTE = { nom: '', type: 'compte_courant', investisseur_id: null, banque: '', exoneration_fiscale: 'aucune' };
function compteInvestisseur(c) {
if (!c.investisseur_id) return null;
return { id: c.investisseur_id, nom: c.investisseur_nom, prenom: c.investisseur_prenom, type: c.investisseur_type, type_fiscal: c.investisseur_type_fiscal };
}
function CompteFormFields({ state, setter, investisseurs }) {
return (
<>
<div>
<label>Nom *</label>
<input required value={state.nom}
onChange={e => setter({ ...state, nom: e.target.value })}
placeholder="ex. Compte courant BNP" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label>Type *</label>
<select value={state.type} onChange={e => setter({ ...state, type: e.target.value })}>
<option value="compte_courant">Compte courant</option>
<option value="pea_pme">PEA-PME</option>
</select>
</div>
<div>
<label>Banque</label>
<input value={state.banque}
onChange={e => setter({ ...state, banque: e.target.value })}
placeholder="ex. BNP Paribas" />
</div>
</div>
<div>
<label>Détenteur</label>
<select value={state.investisseur_id ?? ''}
onChange={e => setter({ ...state, investisseur_id: e.target.value ? Number(e.target.value) : null })}>
<option value=""> Non renseigné </option>
{investisseurs.map(inv => (
<option key={inv.id} value={inv.id}>{memberLabel(inv)}</option>
))}
</select>
</div>
<div>
<label>Exonération fiscale</label>
<select value={state.exoneration_fiscale ?? 'aucune'}
onChange={e => setter({ ...state, exoneration_fiscale: e.target.value })}>
<option value="aucune">Aucune exonération fiscale</option>
<option value="pfnl_5ans">Exonération Impôts sur le revenu (PFNL) si détention 5 ans</option>
</select>
</div>
</>
);
}
export default function ComptesSection() {
const [comptes, setComptes] = useState([]);
const [investisseurs, setInvestisseurs] = useState([]);
const reload = useCallback(async () => {
const [cpts, invs] = await Promise.all([
api.get('/comptes'),
api.get('/investisseurs'),
]);
setComptes(cpts);
setInvestisseurs(invs);
}, []);
useEffect(() => { reload(); }, [reload]);
const [showNew, setShowNew] = useState(false);
const [newCompte, setNewCompte] = useState(EMPTY_COMPTE);
const [editCompte, setEditCompte] = useState(null);
const [err, setErr] = useState(null);
const [confirmDel, setConfirmDel] = useState(null);
const openNew = () => { setNewCompte(EMPTY_COMPTE); setErr(null); setShowNew(true); };
const addCompte = async (e) => {
e.preventDefault(); setErr(null);
try {
await api.post('/comptes', {
nom: newCompte.nom,
type: newCompte.type,
banque: newCompte.banque || null,
investisseur_id: newCompte.investisseur_id ? Number(newCompte.investisseur_id) : null,
exoneration_fiscale: newCompte.exoneration_fiscale ?? 'aucune',
});
setShowNew(false); setNewCompte(EMPTY_COMPTE); reload();
} catch (ex) { setErr(ex.message); }
};
const saveEdit = async (e) => {
e.preventDefault(); setErr(null);
try {
await api.put(`/comptes/${editCompte.id}`, {
nom: editCompte.nom,
type: editCompte.type,
banque: editCompte.banque || null,
investisseur_id: editCompte.investisseur_id ? Number(editCompte.investisseur_id) : null,
exoneration_fiscale: editCompte.exoneration_fiscale ?? 'aucune',
});
setEditCompte(null); reload();
} catch (ex) { setErr(ex.message); }
};
const openEdit = (c) => {
setErr(null);
setEditCompte({ id: c.id, nom: c.nom, type: c.type, banque: c.banque || '', investisseur_id: c.investisseur_id ?? null, exoneration_fiscale: c.exoneration_fiscale ?? 'aucune' });
};
const del = (c) => {
setConfirmDel({
message: `Supprimer le compte "${c.nom}" ?`,
onConfirm: async () => {
try { await api.del(`/comptes/${c.id}`); reload(); }
catch (ex) { setErr(ex.message); }
finally { setConfirmDel(null); }
},
});
};
return (
<>
<div className="card">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 16 }}>
<div>
<h3 style={{ margin: 0 }}>Mes comptes courants</h3>
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: '4px 0 0' }}>
Comptes bancaires et enveloppes financières associés à vos investisseurs.
</p>
</div>
<button className="primary" style={{ whiteSpace: 'nowrap' }} onClick={openNew}>
+ Nouveau compte
</button>
</div>
{err && <div className="error" style={{ marginBottom: 10 }}>{err}</div>}
<table>
<thead>
<tr>
<th>Nom</th>
<th style={{ width: '14%' }}>Type</th>
<th style={{ width: '22%' }}>Détenteur</th>
<th style={{ width: '16%' }}>Banque</th>
<th style={{ width: '10%' }}>Exonération</th>
<th style={{ width: 80 }} />
</tr>
</thead>
<tbody>
{comptes.length === 0 && (
<tr><td colSpan={6} className="text-muted" style={{ textAlign: 'center', fontStyle: 'italic' }}>
Aucun compte défini.
</td></tr>
)}
{comptes.map(c => {
const inv = compteInvestisseur(c);
return (
<tr key={c.id}>
<td style={{ fontWeight: 500 }}>{c.nom}</td>
<td><span className="badge">{TYPE_COMPTE_LABELS[c.type] ?? c.type}</span></td>
<td>{inv ? memberLabel(inv) : <span className="text-muted"></span>}</td>
<td>{c.banque || <span className="text-muted"></span>}</td>
<td>
{c.exoneration_fiscale && c.exoneration_fiscale !== 'aucune' ? (
<span
title={EXONERATION_LABELS[c.exoneration_fiscale] ?? c.exoneration_fiscale}
style={{ cursor: 'help', color: 'var(--success)', fontWeight: 600, fontSize: 'var(--fs-sm)' }}
>Oui</span>
) : (
<span className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>Non</span>
)}
</td>
<td>
<div style={{ display: 'flex', gap: 4 }}>
<button style={{ padding: '3px 10px', fontSize: 11 }} onClick={() => openEdit(c)}>Modifier</button>
<button className="danger" style={{ padding: '3px 10px', fontSize: 11 }} onClick={() => del(c)}></button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* ── Modal création ── */}
<Modal
open={showNew}
title="Nouveau compte"
onClose={() => { setShowNew(false); setNewCompte(EMPTY_COMPTE); setErr(null); }}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%', gap: 8 }}>
<button type="button" onClick={() => { setShowNew(false); setNewCompte(EMPTY_COMPTE); setErr(null); }}>Annuler</button>
<button className="primary" form="form-new-compte" type="submit">Créer</button>
</div>
}
>
<form id="form-new-compte" onSubmit={addCompte} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{err && <div className="error">{err}</div>}
<CompteFormFields state={newCompte} setter={setNewCompte} investisseurs={investisseurs} />
</form>
</Modal>
{/* ── Modal édition ── */}
<Modal
open={!!editCompte}
title="Modifier le compte"
onClose={() => { setEditCompte(null); setErr(null); }}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<button className="danger" type="button" onClick={() => { setEditCompte(null); del(comptes.find(c => c.id === editCompte?.id) ?? editCompte); }}>
Supprimer
</button>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" onClick={() => { setEditCompte(null); setErr(null); }}>Annuler</button>
<button className="primary" form="form-edit-compte" type="submit">Enregistrer</button>
</div>
</div>
}
>
<form id="form-edit-compte" onSubmit={saveEdit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{err && <div className="error">{err}</div>}
<CompteFormFields state={editCompte} setter={setEditCompte} investisseurs={investisseurs} />
</form>
</Modal>
{/* ── ConfirmModal suppression ── */}
<ConfirmModal
open={!!confirmDel}
message={confirmDel?.message}
onConfirm={confirmDel?.onConfirm}
onCancel={() => setConfirmDel(null)}
/>
</>
);
}
@@ -0,0 +1,304 @@
import { useState } from 'react';
import { api } from '../../api.js';
function IconBroom() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 21l9-9"/><path d="M12.22 6.22L17 1.5l5.5 5.5-4.72 4.78"/><path d="M5 17c.5-2 2-3.5 4-4.5l3.5 3.5c-1 2-2.5 3.5-4.5 4"/></svg>;
}
export default function DataCleanupSection() {
const [showModal, setShowModal] = useState(false);
const [loading, setLoading] = useState(false);
const [showReprocessModal, setShowReprocessModal] = useState(false);
const [loadingReprocess, setLoadingReprocess] = useState(false);
const [showDiffereModal, setShowDiffereModal] = useState(false);
const [loadingDiffere, setLoadingDiffere] = useState(false);
const [showBackfillModal, setShowBackfillModal] = useState(false);
const [loadingBackfill, setLoadingBackfill] = useState(false);
const [successMsg, setSuccessMsg] = useState(null);
const [errorMsg, setErrorMsg] = useState(null);
const handleReprocess = async () => {
setLoadingReprocess(true);
setErrorMsg(null);
setSuccessMsg(null);
try {
const { updated } = await api.post('/remboursements/reprocess', {});
setSuccessMsg(`${updated} remboursement${updated > 1 ? 's' : ''} recalculé${updated > 1 ? 's' : ''} avec succès.`);
setShowReprocessModal(false);
} catch (err) {
setErrorMsg(err.message || 'Une erreur est survenue.');
setShowReprocessModal(false);
} finally {
setLoadingReprocess(false);
}
};
const handleReassign = async () => {
setLoading(true);
setErrorMsg(null);
setSuccessMsg(null);
try {
await api.post('/investisseurs/reassign-to-principal', {});
setSuccessMsg('Toutes les données ont été réaffectées au compte principal.');
setShowModal(false);
} catch (err) {
setErrorMsg(err.message || 'Une erreur est survenue.');
setShowModal(false);
} finally {
setLoading(false);
}
};
const handleFixDiffereDates = async () => {
setLoadingDiffere(true);
setErrorMsg(null);
setSuccessMsg(null);
try {
const { updated, detail } = await api.post('/investissements/fix-differe-dates', {});
if (updated === 0) {
setSuccessMsg('Aucune date incohérente détectée sur les prêts différés.');
} else {
setSuccessMsg(
`${updated} prêt${updated > 1 ? 's' : ''} différé${updated > 1 ? 's' : ''} corrigé${updated > 1 ? 's' : ''} : ` +
detail.map(d => d.nom_projet).join(', ') + '.'
);
}
setShowDiffereModal(false);
} catch (err) {
setErrorMsg(err.message || 'Une erreur est survenue.');
setShowDiffereModal(false);
} finally {
setLoadingDiffere(false);
}
};
const handleBackfillComptes = async () => {
setLoadingBackfill(true);
setErrorMsg(null);
setSuccessMsg(null);
try {
const { updated, total } = await api.post('/remboursements/backfill-comptes', {});
if (updated === 0) {
setSuccessMsg(`Aucun remboursement à corriger (${total} vérifié${total > 1 ? 's' : ''}).`);
} else {
setSuccessMsg(`${updated} remboursement${updated > 1 ? 's' : ''} mis à jour sur ${total} vérifié${total > 1 ? 's' : ''}.`);
}
setShowBackfillModal(false);
} catch (err) {
setErrorMsg(err.message || 'Une erreur est survenue.');
setShowBackfillModal(false);
} finally {
setLoadingBackfill(false);
}
};
return (
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Nettoyage de données</h3>
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
Opérations de maintenance sur les données du compte.
</p>
{errorMsg && <div className="error" style={{ marginBottom: 12 }}>{errorMsg}</div>}
{successMsg && <div className="success-msg" style={{ marginBottom: 12 }}>{successMsg}</div>}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderRadius: 8,
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))',
marginBottom: 10 }}>
<div>
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
Recalculer les champs fiscaux des remboursements
</div>
<div className="text-muted" style={{ fontSize: 12 }}>
Recalcule prélèvements sociaux, impôt sur le revenu, intérêts nets et net reçu de tous
vos remboursements selon la fiscalité de chaque plateforme et les taux PFU de l'année.
Met également à jour les retraits automatiques associés.
</div>
</div>
<button
style={{ marginLeft: 16, whiteSpace: 'nowrap', flexShrink: 0 }}
onClick={() => setShowReprocessModal(true)}
disabled={loadingReprocess}
>
Recalculer
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderRadius: 8,
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))',
marginBottom: 10 }}>
<div>
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
Corriger les dates des prêts différés
</div>
<div className="text-muted" style={{ fontSize: 12 }}>
Recalcule la date de 1ère échéance et la date cible à partir de la date de souscription
et de la durée prévue. Seuls les prêts dont les dates s'écartent de plus de 2 ans
de la valeur calculée sont corrigés.
</div>
</div>
<button
style={{ marginLeft: 16, whiteSpace: 'nowrap', flexShrink: 0 }}
onClick={() => setShowDiffereModal(true)}
disabled={loadingDiffere}
>
Corriger
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderRadius: 8,
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))',
marginBottom: 10 }}>
<div>
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
Lier les remboursements aux comptes courants
</div>
<div className="text-muted" style={{ fontSize: 12 }}>
Pour chaque remboursement en mode "Compte courant" sans compte lié, associe automatiquement
le compte de l'investissement ou le premier compte courant du détenteur.
Les remboursements déjà liés ou redirigés vers le porte-monnaie ne sont pas touchés.
</div>
</div>
<button
style={{ marginLeft: 16, whiteSpace: 'nowrap', flexShrink: 0 }}
onClick={() => setShowBackfillModal(true)}
disabled={loadingBackfill}
>
Lier
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderRadius: 8,
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))' }}>
<div>
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
Réaffecter l'ensemble des données au compte principal
</div>
<div className="text-muted" style={{ fontSize: 12 }}>
Investissements, dépôts/retraits tous les enregistrements seront attribués au titulaire principal.
</div>
</div>
<button
className="danger"
style={{ marginLeft: 16, whiteSpace: 'nowrap', flexShrink: 0 }}
onClick={() => setShowModal(true)}
disabled={loading}
>
Réaffecter
</button>
</div>
{showDiffereModal && (
<div className="modal-overlay" onClick={() => setShowDiffereModal(false)}>
<div className="modal" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 16 }}>
<h3 style={{ margin: 0 }}>Corriger les dates des prêts différés</h3>
</div>
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }}>
Pour chaque prêt de type <strong>différé</strong>, la date cible et la date de 1ère échéance
seront recalculées comme suit :
</p>
<p style={{ margin: '0 0 12px', lineHeight: 1.6, fontFamily: 'monospace', fontSize: 13,
background: 'var(--surface-2, var(--bg))', padding: '8px 12px', borderRadius: 6,
border: '1px solid var(--border)' }}>
date souscription + durée (mois)
</p>
<p style={{ margin: '0 0 20px', lineHeight: 1.6 }} className="text-muted">
La correction ne s'applique que si l'écart entre la date existante et la date calculée
dépasse <strong>2 ans</strong>. Les simulations de remboursement associées ne sont pas
recalculées automatiquement.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => setShowDiffereModal(false)} disabled={loadingDiffere}>Annuler</button>
<button className="primary" onClick={() => handleFixDiffereDates()} disabled={loadingDiffere}>
{loadingDiffere ? 'Correction en cours…' : 'Confirmer la correction'}
</button>
</div>
</div>
</div>
)}
{showBackfillModal && (
<div className="modal-overlay" onClick={() => setShowBackfillModal(false)}>
<div className="modal" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 16 }}>
<h3 style={{ margin: 0 }}>Lier les remboursements aux comptes courants</h3>
</div>
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }}>
Pour chaque remboursement en mode <strong>"Compte courant de l'investisseur"</strong> sans compte lié, cette opération va :
</p>
<ul style={{ margin: '0 0 12px', paddingLeft: 20, lineHeight: 1.8, fontSize: 'var(--fs-sm)' }}>
<li>Utiliser le compte défini sur l'investissement lié, si disponible</li>
<li>Sinon, prendre le premier compte courant du détenteur</li>
</ul>
<p style={{ margin: '0 0 20px', lineHeight: 1.6 }} className="text-muted">
Les remboursements déjà liés à un compte ou redirigés vers le porte-monnaie ne sont pas modifiés.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => setShowBackfillModal(false)} disabled={loadingBackfill}>Annuler</button>
<button className="primary" onClick={handleBackfillComptes} disabled={loadingBackfill}>
{loadingBackfill ? 'Traitement en cours' : 'Confirmer'}
</button>
</div>
</div>
</div>
)}
{showReprocessModal && (
<div className="modal-overlay" onClick={() => setShowReprocessModal(false)}>
<div className="modal" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 16 }}>
<h3 style={{ margin: 0 }}>Recalculer les remboursements</h3>
</div>
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }}>
Cette opération va recalculer pour <strong>tous vos remboursements</strong> les champs suivants :
</p>
<ul style={{ margin: '0 0 12px', paddingLeft: 20, lineHeight: 1.8, fontSize: 'var(--fs-sm)' }}>
<li>Prélèvements sociaux et impôt sur le revenu (taux PFU de l'année)</li>
<li>Taxe locale (pour les plateformes avec fiscalité locale)</li>
<li>Intérêts nets et montant net reçu</li>
<li>Montant des retraits automatiques associés</li>
</ul>
<p style={{ margin: '0 0 20px', lineHeight: 1.6 }} className="text-muted">
Les valeurs saisies manuellement seront écrasées. Cette opération ne peut pas être annulée.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => setShowReprocessModal(false)} disabled={loadingReprocess}>Annuler</button>
<button className="primary" onClick={handleReprocess} disabled={loadingReprocess}>
{loadingReprocess ? 'Recalcul en cours…' : 'Confirmer le recalcul'}
</button>
</div>
</div>
</div>
)}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal" style={{ maxWidth: 440 }} onClick={e => e.stopPropagation()}>
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 16 }}>
<h3 style={{ margin: 0, color: 'var(--danger, #ef4444)' }}> Action irréversible</h3>
</div>
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }}>
Cette action va réaffecter <strong>l'ensemble de vos investissements et mouvements financiers</strong> au compte principal.
</p>
<p style={{ margin: '0 0 20px', lineHeight: 1.6 }} className="text-muted">
Les données actuellement rattachées à d'autres membres ou entreprises seront transférées au titulaire principal. Cette opération ne peut pas être annulée.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => setShowModal(false)} disabled={loading}>Annuler</button>
<button className="danger" onClick={handleReassign} disabled={loading}>
{loading ? 'Réaffectation…' : 'Confirmer la réaffectation'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,499 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../../api.js';
import { fmtDate } from '../../utils/format.js';
import { useInvestisseur } from '../../context/InvestisseurContext.jsx';
import ResultBanner from '../../components/ResultBanner.jsx';
function dlBlob(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code ?? '—';
const FISCALITE_LABELS = {
flat_tax: 'Flat Tax',
sans_fiscalite_locale: 'Sans fiscalité locale',
avec_fiscalite_locale: 'Avec fiscalité locale',
};
const METHODE_REMB_LABELS = {
portefeuille: 'Porte-monnaie de la plateforme',
compte_courant: "Compte courant de l'investisseur",
choix_investisseur: "Au choix de l'investisseur (sur la plateforme)",
};
/** Reconstruit un objet investisseur minimal depuis les colonnes dénormalisées de la plateforme */
function IconCSV() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="10" y1="9" x2="14" y2="9"/></svg>;
}
function IconXLS() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l2 2 4-4"/></svg>;
}
function IconJSON() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M8 13h1.5a1.5 1.5 0 0 1 0 3H8v-3z"/><path d="M14 13h2v1.5a1.5 1.5 0 0 1-3 0V13z"/></svg>;
}
function IconImport() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 14 12 9 17 14"/><line x1="12" y1="9" x2="12" y2="21"/></svg>;
}
/* ── ExportDropdown ──────────────────────────────────────────── */
function ExportDropdown({ disabled, onCSV, onXLS, onJSON, title = 'Exporter' }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const handler = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const choose = (fn) => { setOpen(false); fn(); };
return (
<div ref={ref} style={{ position: 'relative' }}>
<button type="button" className="icon-btn" disabled={disabled}
onClick={() => setOpen(o => !o)} title={title}
aria-haspopup="menu" aria-expanded={open}>
<IconExport />
</button>
{open && (
<div className="export-dropdown" role="menu">
<button role="menuitem" onClick={() => choose(onCSV)}>
<IconCSV /><span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
</button>
<button role="menuitem" onClick={() => choose(onXLS)}>
<IconXLS /><span><strong>Format Excel</strong><small>Fichier .xls natif Microsoft</small></span>
</button>
{onJSON && (
<button role="menuitem" onClick={() => choose(onJSON)}>
<IconJSON /><span><strong>Format JSON</strong><small>Données structurées</small></span>
</button>
)}
</div>
)}
</div>
);
}
/* ── Helpers PFU ─────────────────────────────────────────────── */
function IconUpload() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>;
}
/* ── Imports — constantes ────────────────────────────────────── */
const MODULES = {
depots_retraits: {
label: 'Dépôts / Retraits',
required: ['date_operation', 'type', 'montant'],
optional: ['plateforme_id', 'libelle', 'reference'],
needsInvestisseur: true,
},
investissements: {
label: 'Investissements',
required: ['nom_projet', 'date_souscription', 'montant_investi'],
optional: ['plateforme_id', 'emetteur', 'date_premiere_echeance', 'date_cible', 'taux_interet', 'duree_mois', 'type_remb', 'freq_interets', 'statut', 'reference'],
needsInvestisseur: true,
},
remboursements: {
label: 'Remboursements',
required: ['investissement_id', 'date_remb'],
optional: ['capital', 'interets_bruts', 'prelev_sociaux', 'prelev_forfaitaire', 'net_recu', 'statut'],
needsInvestisseur: true,
},
plateformes: {
label: 'Plateformes',
required: ['nom'],
optional: ['url', 'notes'],
needsInvestisseur: false,
note: 'Les plateformes dont le nom existe déjà seront ignorées (pas d\'écrasement).',
},
taux_pfu: {
label: 'Flat Tax — Taux PFU',
required: ['annee', 'pfu_total', 'impot_revenu', 'prelev_sociaux'],
optional: [],
needsInvestisseur: false,
global: true,
note: 'Table de référence globale. Si une année existe déjà, ses taux seront mis à jour (upsert).',
},
};
const MODULE_LABEL = {
depots_retraits: 'Dépôts / Retraits',
investissements: 'Investissements',
remboursements: 'Remboursements',
plateformes: 'Plateformes',
taux_pfu: 'Flat Tax — Taux PFU',
};
/* ── Imports — composant dossier ─────────────────────────────── */
function DossierImport({
activeId, navigate,
dossierFile, setDossierFile,
dossierPreview, setDossierPreview,
dossierResult, setDossierResult,
dossierBusy, setDossierBusy,
dossierErr, setDossierErr,
dossierInputRef, reloadHistory,
}) {
const missingInv = !activeId;
const onFileChange = (e) => {
const f = e.target.files[0];
setDossierFile(f || null);
setDossierPreview(null); setDossierResult(null); setDossierErr(null);
if (!f) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const parsed = JSON.parse(ev.target.result);
if (parsed.type !== 'dossier_investissement') {
setDossierErr('Ce fichier n\'est pas un dossier investissement valide (type incorrect).');
return;
}
setDossierPreview(parsed);
} catch { setDossierErr('Fichier JSON invalide — vérifiez la syntaxe.'); }
};
reader.readAsText(f);
};
const onImport = async () => {
if (!dossierPreview) return;
setDossierBusy(true); setDossierErr(null); setDossierResult(null);
try {
const r = await api.post('/imports/dossier', { dossier: dossierPreview });
setDossierResult(r);
setDossierFile(null); setDossierPreview(null);
if (dossierInputRef.current) dossierInputRef.current.value = '';
reloadHistory();
} catch (e) { setDossierErr(e.message); }
finally { setDossierBusy(false); }
};
const dp = dossierPreview;
const inv = dp?.investissement;
return (
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Import Dossier investissement</h3>
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', marginBottom: 12 }}>
Restaure ou migre un dossier complet (investissement + remboursements + historique) depuis un fichier
<code style={{ margin: '0 4px' }}>.json</code> exporté par cette application.
Si le dossier existe déjà, il sera mis à jour ; sinon il sera créé.
</p>
{missingInv && (
<div className="error" style={{ marginBottom: 10 }}>
Sélectionnez un investisseur actif avant d'importer un dossier.
</div>
)}
<div className="row" style={{ gap: 10, alignItems: 'flex-end' }}>
<div style={{ flex: 1 }}>
<label>Fichier dossier <code>.json</code></label>
<input ref={dossierInputRef} type="file" accept=".json"
disabled={missingInv} onChange={onFileChange} />
</div>
</div>
{dossierErr && <div className="error" style={{ marginTop: 10 }}>{dossierErr}</div>}
{dp && inv && (
<div style={{ marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 14 }}>
<h4 style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)' }}>Aperçu du dossier</h4>
<table style={{ marginBottom: 0 }}>
<tbody>
<tr><td style={{ width: 200 }}>Projet</td><td><strong>{inv.nom_projet}</strong></td></tr>
<tr><td>Plateforme</td><td>{dp.plateforme?.nom}</td></tr>
<tr><td>Date souscription</td><td>{fmtDate(inv.date_souscription)}</td></tr>
<tr><td>Montant investi</td><td>{inv.montant_investi} €</td></tr>
<tr><td>Statut</td><td>{inv.statut}</td></tr>
<tr><td>Remboursements</td><td>{dp.remboursements?.length ?? 0} enregistrement(s)</td></tr>
<tr><td>Réinvestissements</td><td>{dp.reinvestissements?.length ?? 0} enregistrement(s)</td></tr>
<tr><td>Projections</td><td>{dp.projections?.length ?? 0} échéance(s)</td></tr>
<tr><td>Historique</td><td>{dp.historique?.length ?? 0} entrée(s)</td></tr>
<tr><td>Exporté le</td><td className="text-muted" style={{ fontSize: 11 }}>{dp.exported_at}</td></tr>
</tbody>
</table>
<div style={{ marginTop: 12, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button onClick={() => { setDossierFile(null); setDossierPreview(null); if (dossierInputRef.current) dossierInputRef.current.value = ''; }}>
Annuler
</button>
<button className="primary" onClick={onImport} disabled={dossierBusy || missingInv}>
{dossierBusy ? '' : 'Importer ce dossier'}
</button>
</div>
</div>
)}
{dossierResult && (
<div className="success-msg" style={{ marginTop: 12 }}>
{dossierResult.action === 'created' ? ' Dossier créé avec succès.' : ' Dossier mis à jour avec succès.'}
{' '}
<button
style={{ marginLeft: 8, fontSize: 'var(--fs-xs)', padding: '2px 8px' }}
onClick={() => navigate(`/investissements/${dossierResult.investissementId}`)}
>
Ouvrir le dossier →
</button>
</div>
)}
</div>
);
}
/* ── Imports — section principale ────────────────────────────── */
export default function ImportsSection() {
const { activeId } = useInvestisseur();
const navigate = useNavigate();
const [module, setModule] = useState('depots_retraits');
const [file, setFile] = useState(null);
const [preview, setPreview] = useState(null);
const [mapping, setMapping] = useState({});
const [defaults, setDefaults] = useState({});
const [plats, setPlats] = useState([]);
const [investissements, setInvestissements] = useState([]);
const [history, setHistory] = useState([]);
const [result, setResult] = useState(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState(null);
const [dossierFile, setDossierFile] = useState(null);
const [dossierPreview, setDossierPreview] = useState(null);
const [dossierResult, setDossierResult] = useState(null);
const [dossierBusy, setDossierBusy] = useState(false);
const [dossierErr, setDossierErr] = useState(null);
const dossierInputRef = useRef(null);
useEffect(() => {
api.get('/imports/history').then(setHistory).catch(() => {});
api.get('/plateformes').then(setPlats).catch(() => {});
}, []);
useEffect(() => {
if (!activeId) return;
api.get('/investissements').then(setInvestissements).catch(() => {});
}, [activeId]);
const def = MODULES[module];
const allTargets = def ? [...def.required, ...def.optional] : [];
const missingInv = def?.needsInvestisseur && !activeId;
const onPreview = async () => {
if (!file) return;
setBusy(true); setErr(null); setResult(null);
try {
const fd = new FormData();
fd.append('file', file);
const r = await api.upload('/imports/preview', fd);
setPreview(r);
const auto = {};
for (const t of allTargets) {
const col = r.headers.find(h => h.toLowerCase().replace(/\W/g, '_') === t);
if (col) auto[t] = col;
}
setMapping(auto);
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
};
const apply = async () => {
setBusy(true); setErr(null);
try {
const r = await api.post('/imports/apply', {
tempId: preview.tempId, module, mapping, defaults,
originalFilename: file?.name ?? preview.filename,
});
setResult({
ok: true,
msg: `✔ Import terminé : ${r.inserted} / ${r.total} lignes insérées${r.skipped > 0 ? `, ${r.skipped} ignorées` : ''}.${r.errors?.length > 0 ? ` (${r.errors.length} avertissement(s))` : ''}`,
});
setPreview(null); setFile(null); setMapping({}); setDefaults({});
api.get('/imports/history').then(setHistory).catch(() => {});
if (module === 'plateformes') api.get('/plateformes').then(setPlats).catch(() => {});
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
};
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
return (
<>
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>1. Fichier source</h3>
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
Importez des données depuis un fichier Excel, CSV ou JSON.
</p>
<div className="row">
<div>
<label>Module cible</label>
<select value={module} onChange={e => {
setModule(e.target.value);
setPreview(null); setMapping({}); setResult(null); setErr(null);
}}>
{Object.entries(MODULES).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
</div>
<div style={{ flex: 2 }}>
<label>Fichier .xlsx, .csv ou .json</label>
<input type="file" accept=".xlsx,.xls,.csv,.json" onChange={e => {
setFile(e.target.files[0]);
setPreview(null); setResult(null); setErr(null);
}} />
</div>
<div>
<button className="primary" onClick={onPreview} disabled={!file || busy || missingInv}>
{busy ? '' : 'Analyser'}
</button>
</div>
</div>
{def?.note && (
<div className="import-module-note">
{def.global && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ flexShrink: 0, marginTop: 1, color: 'var(--warning)' }}>
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
)}
{def.note}
</div>
)}
{missingInv && (
<div className="error" style={{ marginTop: 10 }}>
Sélectionnez un investisseur actif avant d'importer ce module.
</div>
)}
{err && <div className="error" style={{ marginTop: 12 }}>{err}</div>}
<ResultBanner result={result} onDismiss={() => setResult(null)} style={{ marginTop: 12 }} />
</div>
{preview && (
<>
<div className="card">
<h3 style={{ marginTop: 0 }}>2. Mappage des colonnes</h3>
<p className="text-muted" style={{ fontSize: 12 }}>
Fichier : <strong>{preview.filename}</strong> feuille <em>{preview.sheetName}</em> {preview.allRowCount} lignes.
{' '}Champs marqués <span style={{ color: 'var(--danger)' }}>*</span> obligatoires.
{' '}Si la colonne n'existe pas, fournissez une valeur par défaut.
</p>
<table>
<thead>
<tr><th>Champ cible</th><th>Colonne Excel</th><th>Valeur par défaut</th></tr>
</thead>
<tbody>
{allTargets.map(t => {
const isReq = def.required.includes(t);
return (
<tr key={t}>
<td>
<code style={{ fontSize: 11 }}>{t}</code>
{isReq && <span style={{ color: 'var(--danger)' }}> *</span>}
</td>
<td>
<select value={mapping[t] || ''} onChange={e => setMapping({ ...mapping, [t]: e.target.value })}>
<option value="">— ignorer —</option>
{preview.headers.map(h => <option key={h} value={h}>{h}</option>)}
</select>
</td>
<td>
{t === 'plateforme_id' ? (
<select value={defaults[t] || ''} onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}>
<option value="">—</option>
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
</select>
) : t === 'investissement_id' ? (
<select value={defaults[t] || ''} onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}>
<option value="">—</option>
{investissements.map(i => <option key={i.id} value={i.id}>{i.nom_projet}</option>)}
</select>
) : (
<input value={defaults[t] || ''} onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}
placeholder={t === 'statut' ? 'ex. en_cours' : t === 'type' ? 'ex. depot' : ''} />
)}
</td>
</tr>
);
})}
</tbody>
</table>
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => { setPreview(null); setMapping({}); }}>Annuler</button>
<button className="primary" onClick={apply} disabled={busy || missingInv}>
{busy ? '' : `Importer ${preview.allRowCount} lignes`}
</button>
</div>
</div>
<div className="card">
<h3 style={{ marginTop: 0 }}>Aperçu (10 premières lignes)</h3>
<div style={{ overflowX: 'auto' }}>
<table>
<thead><tr>{preview.headers.map(h => <th key={h}>{h}</th>)}</tr></thead>
<tbody>
{preview.sampleRows.map((r, i) => (
<tr key={i}>{preview.headers.map(h => <td key={h}>{String(r[h] ?? '')}</td>)}</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
<DossierImport
activeId={activeId}
navigate={navigate}
dossierFile={dossierFile} setDossierFile={setDossierFile}
dossierPreview={dossierPreview} setDossierPreview={setDossierPreview}
dossierResult={dossierResult} setDossierResult={setDossierResult}
dossierBusy={dossierBusy} setDossierBusy={setDossierBusy}
dossierErr={dossierErr} setDossierErr={setDossierErr}
dossierInputRef={dossierInputRef}
reloadHistory={() => api.get('/imports/history').then(setHistory).catch(() => {})}
/>
<div className="card">
<h3 style={{ marginTop: 0 }}>Historique des imports</h3>
<table>
<thead>
<tr>
<th>Date</th><th>Module</th><th>Fichier</th>
<th className="num">Total</th><th className="num">OK</th><th className="num">KO</th>
</tr>
</thead>
<tbody>
{history.length === 0 && (
<tr><td colSpan={6} className="text-muted" style={{ textAlign: 'center' }}>Aucun import</td></tr>
)}
{history.map(h => (
<tr key={h.id}>
<td>{fmtDate(h.created_at)}</td>
<td>{MODULE_LABEL[h.module] ?? h.module}</td>
<td className="text-muted" style={{ fontSize: 11 }}>{h.filename}</td>
<td className="num">{h.rows_total}</td>
<td className="num" style={{ color: 'var(--success)' }}>{h.rows_inserted}</td>
<td className="num" style={{ color: h.rows_skipped > 0 ? 'var(--warning)' : undefined }}>{h.rows_skipped}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}
@@ -0,0 +1,94 @@
import { useState } from 'react';
import { useUi } from '../../context/UiContext.jsx';
export default function MaFiscaliteSection() {
const { pfoAssujetti, setPfoAssujetti } = useUi();
const [showPfoDetail, setShowPfoDetail] = useState(false);
return (
<div>
<h2 style={{ margin: '0 0 6px', fontSize: 18 }}>Ma fiscalité</h2>
<p style={{ margin: '0 0 24px', color: 'var(--text-muted)', fontSize: 13 }}>
Paramètres fiscaux personnels applicables à vos revenus de placements.
</p>
<div style={{ fontWeight: 700, fontSize: 'var(--fs-md)', marginBottom: 10 }}>
Fiscalité des plateformes étrangères
</div>
<div className="card" style={{ marginBottom: 16, borderLeft: `4px solid ${pfoAssujetti ? 'var(--primary)' : 'var(--border)'}`, transition: 'border-color .2s' }}>
{/* Ligne titre + chevron + toggle */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<button
type="button"
onClick={() => setShowPfoDetail(v => !v)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex', alignItems: 'center', color: 'var(--text)' }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ transform: showPfoDetail ? 'rotate(180deg)' : 'none', transition: 'transform .2s', marginRight: 6, flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
<span style={{ fontWeight: 700, fontSize: 'var(--fs-base)' }}>
CERFA 2778-SD Prélèvement Forfaitaire Obligatoire (PFO) pour des revenus de source étrangère
</span>
</button>
<div style={{ marginLeft: 'auto', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 'var(--fs-xs)', fontWeight: 600, color: pfoAssujetti ? 'var(--primary)' : 'var(--text-muted)' }}>
{pfoAssujetti ? 'Activé' : 'Désactivé'}
</span>
<button
type="button"
onClick={() => setPfoAssujetti(!pfoAssujetti)}
style={{
width: 48, height: 26, borderRadius: 13, border: 'none',
background: pfoAssujetti ? 'var(--primary)' : 'var(--border)',
cursor: 'pointer', position: 'relative', transition: 'background .2s',
}}
>
<span style={{
position: 'absolute', top: 3,
left: pfoAssujetti ? 25 : 3,
width: 20, height: 20, borderRadius: '50%',
background: '#fff', transition: 'left .2s',
boxShadow: '0 1px 4px rgba(0,0,0,0.2)',
}} />
</button>
</div>
</div>
{showPfoDetail && (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)', lineHeight: 1.6 }}>
Les intérêts perçus via des plateformes <strong>étrangères</strong> sont soumis à un prélèvement
forfaitaire obligatoire non libératoire de <strong>12,8 %</strong> (+ prélèvements sociaux),
à déclarer mensuellement via le formulaire <strong>2778-SD</strong> dans les 15 premiers jours
du mois suivant l'encaissement.
</div>
<div style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)', lineHeight: 1.6, marginTop: 8 }}>
Ce prélèvement s'applique aux personnes physiques fiscalement domiciliées en France dont
le <strong>revenu fiscal de référence</strong> de l'avant-dernière année est égal ou supérieur à :
</div>
<div style={{ display: 'flex', gap: 12, marginTop: 8, flexWrap: 'wrap' }}>
<div style={{
padding: '6px 14px', borderRadius: 20,
background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.2)',
fontSize: 'var(--fs-sm)', fontWeight: 600,
}}>25 000 € — célibataire, divorcé ou veuf</div>
<div style={{
padding: '6px 14px', borderRadius: 20,
background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.2)',
fontSize: 'var(--fs-sm)', fontWeight: 600,
}}>50 000 € — couple marié ou pacsé</div>
</div>
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 8 }}>
En dessous de ces seuils, vous êtes dispensé du PFO — mais les prélèvements sociaux restent dus.
Le PFO versé via la 2778-SD constitue un simple acompte d'impôt sur le revenu,
imputable sur l'impôt définitif calculé lors de la déclaration 2042.
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,736 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../../api.js';
import InvSelect from '../../components/InvSelect.jsx';
import Modal from '../../components/Modal.jsx';
import ConfirmModal from '../../components/ConfirmModal.jsx';
import CountrySelect, { COUNTRIES, FlagIcon } from '../../components/CountrySelect.jsx';
import ResultBanner from '../../components/ResultBanner.jsx';
import { useInvestisseur } from '../../context/InvestisseurContext.jsx';
import { memberLabel, fmtDate } from '../../utils/format.js';
function dlBlob(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code ?? '—';
const FISCALITE_LABELS = {
flat_tax: 'Flat Tax',
sans_fiscalite_locale: 'Sans fiscalité locale',
avec_fiscalite_locale: 'Avec fiscalité locale',
};
const METHODE_REMB_LABELS = {
portefeuille: 'Porte-monnaie de la plateforme',
compte_courant: "Compte courant de l'investisseur",
choix_investisseur: "Au choix de l'investisseur (sur la plateforme)",
};
/** Reconstruit un objet investisseur minimal depuis les colonnes dénormalisées de la plateforme */
function platInvestisseur(p) {
if (!p.investisseur_id) return null;
return { id: p.investisseur_id, nom: p.investisseur_nom, prenom: p.investisseur_prenom, type: p.investisseur_type, type_fiscal: p.investisseur_type_fiscal };
}
function fmtFiscalite(p) {
if (!p.fiscalite) return '—';
const base = FISCALITE_LABELS[p.fiscalite] ?? p.fiscalite;
if (p.fiscalite === 'avec_fiscalite_locale' && p.taux_fiscalite_locale != null) {
return `${base} (${p.taux_fiscalite_locale} %)`;
}
return base;
}
const EMPTY_PLAT = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', methode_remboursement: 'portefeuille', investisseur_id: null, date_ouverture: '', logo_filename: null, type_pret_defaut: '', freq_interets_defaut: '', referentiel_id: null };
const LOGO_BASE = (import.meta.env.VITE_API_URL || '/api').replace(/\/api$/, '') + '/api/logos/';
const logoUrl = (filename) => filename ? LOGO_BASE + filename : null;
function applyDomiciliationChange(state, newDomicil) {
const next = { ...state, domiciliation: newDomicil };
if (newDomicil === 'FR') {
next.fiscalite = 'flat_tax';
next.taux_fiscalite_locale = '';
} else if (state.fiscalite === 'flat_tax') {
next.fiscalite = 'sans_fiscalite_locale';
next.taux_fiscalite_locale = '';
}
return next;
}
function applyFiscaliteChange(state, newFiscalite) {
const next = { ...state, fiscalite: newFiscalite };
if (newFiscalite !== 'avec_fiscalite_locale') next.taux_fiscalite_locale = '';
return next;
}
function platsToCSV(plats) {
const BOM = '';
const sep = ';';
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
const headers = ['ID', 'Nom', 'URL', 'Domiciliation', 'Fiscalité', 'Taux fiscal local (%)', 'Créé le'];
const rows = plats.map(p => [
p.id, p.nom, p.url || '',
countryLabel(p.domiciliation),
FISCALITE_LABELS[p.fiscalite] ?? p.fiscalite ?? '',
p.taux_fiscalite_locale ?? '',
p.created_at ? new Date(p.created_at).toLocaleDateString('fr-FR') : '',
]);
return BOM + [headers, ...rows].map(r => r.map(q).join(sep)).join('\r\n');
}
function platsToXLS(plats) {
const esc = v => String(v ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
const cell = (v, t = 'String') => `<Cell><Data ss:Type="${t}">${esc(v)}</Data></Cell>`;
const mkRow = cells => ` <Row>${cells.join('')}</Row>`;
const header = mkRow(['ID','Nom','URL','Domiciliation','Fiscalité','Taux fiscal local (%)','Créé le'].map(h => cell(h)));
const dataRows = plats.map(p => mkRow([
cell(p.id, 'Number'), cell(p.nom), cell(p.url || ''),
cell(countryLabel(p.domiciliation)),
cell(FISCALITE_LABELS[p.fiscalite] ?? p.fiscalite ?? ''),
cell(p.taux_fiscalite_locale ?? ''),
cell(p.created_at ? new Date(p.created_at).toLocaleDateString('fr-FR') : ''),
])).join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
<Styles><Style ss:ID="h"><Font ss:Bold="1"/></Style></Styles>
<Worksheet ss:Name="Plateformes">
<Table>
${header}
${dataRows}
</Table>
</Worksheet>
</Workbook>`;
}
/* ── Icônes utilitaires ──────────────────────────────────────── */
function IconExport() {
return <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>;
}
function IconCSV() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="10" y1="9" x2="14" y2="9"/></svg>;
}
function IconXLS() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l2 2 4-4"/></svg>;
}
function IconJSON() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M8 13h1.5a1.5 1.5 0 0 1 0 3H8v-3z"/><path d="M14 13h2v1.5a1.5 1.5 0 0 1-3 0V13z"/></svg>;
}
function IconImport() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 14 12 9 17 14"/><line x1="12" y1="9" x2="12" y2="21"/></svg>;
}
/* ── ExportDropdown ──────────────────────────────────────────── */
function ExportDropdown({ disabled, onCSV, onXLS, onJSON, title = 'Exporter' }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const handler = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const choose = (fn) => { setOpen(false); fn(); };
return (
<div ref={ref} style={{ position: 'relative' }}>
<button type="button" className="icon-btn" disabled={disabled}
onClick={() => setOpen(o => !o)} title={title}
aria-haspopup="menu" aria-expanded={open}>
<IconExport />
</button>
{open && (
<div className="export-dropdown" role="menu">
<button role="menuitem" onClick={() => choose(onCSV)}>
<IconCSV /><span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
</button>
<button role="menuitem" onClick={() => choose(onXLS)}>
<IconXLS /><span><strong>Format Excel</strong><small>Fichier .xls natif Microsoft</small></span>
</button>
{onJSON && (
<button role="menuitem" onClick={() => choose(onJSON)}>
<IconJSON /><span><strong>Format JSON</strong><small>Données structurées</small></span>
</button>
)}
</div>
)}
</div>
);
}
/* ── Helpers PFU ─────────────────────────────────────────────── */
function PlatDetailPanel({ plat, onEdit }) {
const navigate = useNavigate();
const [platCatsInv, setPlatCatsInv] = useState([]);
const [platSectsInv, setPlatSectsInv] = useState([]);
useEffect(() => {
if (!plat) { setPlatCatsInv([]); setPlatSectsInv([]); return; }
Promise.all([
api.get(`/plateformes/${plat.id}/categories-inv`).catch(() => []),
api.get(`/plateformes/${plat.id}/secteurs-inv`).catch(() => []),
]).then(([cats, sects]) => { setPlatCatsInv(cats); setPlatSectsInv(sects); });
}, [plat?.id]);
if (!plat) return (
<div className="dr-detail dr-detail-empty">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.25, marginBottom: 8 }}>
<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
<span>Sélectionnez une plateforme</span>
</div>
);
const inv = platInvestisseur(plat);
const fields = [
{ label: 'Plateforme', value: plat.nom },
plat.url && {
label: 'Site web',
value: <a href={plat.url} target="_blank" rel="noreferrer"
style={{ wordBreak: 'break-all', color: 'var(--primary)' }}>{plat.url}</a>,
},
{ label: 'Détenteur', value: inv ? memberLabel(inv) : '—' },
plat.date_ouverture && { label: "Date d'ouverture", value: fmtDate(plat.date_ouverture) },
{ label: 'Domiciliation', value: plat.domiciliation ? <span style={{ display:'inline-flex', alignItems:'center', gap:5 }}><FlagIcon code={plat.domiciliation} size={16} />{countryLabel(plat.domiciliation)}</span> : '—' },
{ label: 'Fiscalité', value: fmtFiscalite(plat) },
plat.domiciliation === 'FR' && {
label: 'Déclaration 2561',
value: (plat.type_produit_fiscal ?? '2TT') === '2TR'
? 'Case 2TR — Produits de placement à revenu fixe'
: 'Case 2TT — Produits des minibons et prêts participatifs',
},
{ label: 'Méthode de remboursement', value: METHODE_REMB_LABELS[plat.methode_remboursement] ?? '—' },
platCatsInv.length > 0 && {
label: "Catégories d'investissement",
value: <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>{platCatsInv.map(c => <span key={c.id} className="chip-cat">{c.nom}</span>)}</div>,
},
platSectsInv.length > 0 && {
label: "Secteurs d'investissement",
value: <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>{platSectsInv.map(s => <span key={s.id} className="chip-sect">{s.nom}</span>)}</div>,
},
plat.type_pret_defaut && { label: 'Type de prêt (défaut)', value: { in_fine: 'In fine', amortissable: 'Amortissable', differe: 'Différé' }[plat.type_pret_defaut] ?? plat.type_pret_defaut },
plat.freq_interets_defaut && { label: 'Périodicité (défaut)', value: { mensuel: 'Mensuelle', trimestriel: 'Trimestrielle', in_fine: 'In fine' }[plat.freq_interets_defaut] ?? plat.freq_interets_defaut },
{
label: 'Investissements',
value: plat.nb_investissements != null
? `${plat.nb_investissements} investissement${plat.nb_investissements !== 1 ? 's' : ''}`
: '—',
},
plat.notes && { label: 'Notes', value: plat.notes },
].filter(Boolean);
const logo = logoUrl(plat.logo_filename);
return (
<div className="dr-detail">
{logo && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '12px 0 4px' }}>
<img src={logo} alt={`Logo ${plat.nom}`} className="logo-plateforme"
style={{ maxHeight: 56, maxWidth: 160, objectFit: 'contain' }}
onError={e => { e.currentTarget.style.display = 'none'; }}
/>
</div>
)}
<div className="dr-detail-title">Détail de la plateforme</div>
<div className="dr-detail-fields">
{fields.map(f => (
<div className="dr-detail-field" key={f.label}>
<span className="dr-detail-label">{f.label}</span>
<span className="dr-detail-value">{f.value}</span>
</div>
))}
</div>
<div className="dr-detail-footer">
{plat.referentiel_id && (
<button
className="ghost"
onClick={() => navigate(`/referentiel/${plat.referentiel_id}`)}
style={{ marginBottom: 8, width: '100%', fontSize: 13 }}
>
Voir le profil de la plateforme
</button>
)}
<button className="dr-detail-edit-btn" onClick={() => onEdit(plat)}>
Modifier la plateforme
</button>
</div>
</div>
);
}
/* ── Panneau de détail PFU ───────────────────────────────────── */
export default function PlateformesSection() {
// ── State ──────────────────────────────────────────────────────
const [plats, setPlats] = useState([]);
const [investisseurs, setInvestisseurs] = useState([]);
const [referentiel, setReferentiel] = useState([]);
const [newPlat, setNewPlat] = useState(EMPTY_PLAT);
const [newPlatLogoFile, setNewPlatLogoFile] = useState(null);
const [newPlatLogoPreview, setNewPlatLogoPreview] = useState(null);
const [editPlat, setEditPlat] = useState(null);
const [editPlatLogoFile, setEditPlatLogoFile] = useState(null);
const [editPlatLogoPreview, setEditPlatLogoPreview] = useState(null);
const [selectedPlat, setSelectedPlat] = useState(null);
const [showNewPlat, setShowNewPlat] = useState(false);
const [platOpenMenu, setPlatOpenMenu] = useState(null); // { plat, x, y }
const [platExporting, setPlatExporting] = useState(false);
const [platImportResult, setPlatImportResult] = useState(null);
// Catégories d'investissement (globales + privées)
const [categoriesInv, setCategoriesInv] = useState([]);
const [selectedCatInv, setSelectedCatInv] = useState(null);
const [editingCatInv, setEditingCatInv] = useState(null); // id en cours d'édition
const [editingNomCatInv, setEditingNomCatInv] = useState('');
const [newCatInvNom, setNewCatInvNom] = useState('');
const [showNewCatInv, setShowNewCatInv] = useState(false);
// Secteurs d'investissement (globaux + privés)
const [secteursInv, setSecteursInv] = useState([]);
const [selectedSectInv, setSelectedSectInv] = useState(null);
const [editingSectInv, setEditingSectInv] = useState(null);
const [editingNomSectInv, setEditingNomSectInv] = useState('');
const [newSectInvNom, setNewSectInvNom] = useState('');
const [showNewSectInv, setShowNewSectInv] = useState(false);
// PFU
const [showPfoDetail, setShowPfoDetail] = useState(false);
const [err, setErr] = useState(null);
const [msg, setMsg] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const platImportRef = useRef(null);
const load = async () => {
const [p, invs, ref, catInv, sectInv] = await Promise.all([
api.get('/plateformes'),
api.get('/investisseurs'),
api.get('/plateformes/referentiel-list'),
api.get('/categories-inv'),
api.get('/secteurs-inv'),
]);
setPlats(p);
setInvestisseurs(invs);
setReferentiel(ref);
setCategoriesInv(catInv);
setSecteursInv(sectInv);
setSelectedPlat(prev => prev ? (p.find(x => x.id === prev.id) ?? null) : null);
};
useEffect(() => { load(); }, []); // eslint-disable-line
useEffect(() => {
if (plats.length === 0) return;
setSelectedPlat(prev => prev ? prev : plats[0]);
}, [plats]); // eslint-disable-line
const handleLogoFile = (file, setFile, setPreview) => {
if (!file) { setFile(null); setPreview(null); return; }
setFile(file);
const reader = new FileReader();
reader.onload = (ev) => setPreview(ev.target.result);
reader.readAsDataURL(file);
};
/* ── Catégories d'investissement (privées) ──────────────────────── */
const saveCatInv = async (nom) => {
if (!nom.trim()) return;
try {
const row = await api.post('/categories-inv', { nom: nom.trim() });
setCategoriesInv(prev => [...prev, row].sort((a, b) =>
b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
setShowNewCatInv(false); setNewCatInvNom(''); setErr(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const renameCatInv = async (id, nom) => {
if (!nom.trim()) return;
try {
await api.put(`/categories-inv/${id}`, { nom: nom.trim() });
setCategoriesInv(prev => prev.map(c => c.id === id ? { ...c, nom: nom.trim() } : c));
setEditingCatInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const delCatInv = async (id) => {
try {
await api.del(`/categories-inv/${id}`);
setCategoriesInv(prev => prev.filter(c => c.id !== id));
if (selectedCatInv?.id === id) setSelectedCatInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
/* ── Secteurs d'investissement (privés) ──────────────────────── */
const saveSectInv = async (nom) => {
if (!nom.trim()) return;
try {
const row = await api.post('/secteurs-inv', { nom: nom.trim() });
setSecteursInv(prev => [...prev, row].sort((a, b) =>
b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
setShowNewSectInv(false); setNewSectInvNom(''); setErr(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const renameSectInv = async (id, nom) => {
if (!nom.trim()) return;
try {
await api.put(`/secteurs-inv/${id}`, { nom: nom.trim() });
setSecteursInv(prev => prev.map(s => s.id === id ? { ...s, nom: nom.trim() } : s));
setEditingSectInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const delSectInv = async (id) => {
try {
await api.del(`/secteurs-inv/${id}`);
setSecteursInv(prev => prev.filter(s => s.id !== id));
if (selectedSectInv?.id === id) setSelectedSectInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
/** Upload le logo vers le serveur après que la plateforme a été créée/sauvée */
const uploadLogo = async (platId, file) => {
if (!file) return null;
const fd = new FormData();
fd.append('logo', file);
return api.upload(`/plateformes/${platId}/logo`, fd);
};
/* ── Plateformes ─────────────────────────────────────────────── */
const addPlat = async (e) => {
e.preventDefault(); setErr(null); setMsg(null);
try {
const created = await api.post('/plateformes', {
...newPlat,
taux_fiscalite_locale: newPlat.fiscalite === 'avec_fiscalite_locale' && newPlat.taux_fiscalite_locale !== ''
? Number(newPlat.taux_fiscalite_locale) : null,
methode_remboursement: newPlat.methode_remboursement || 'portefeuille',
investisseur_id: newPlat.investisseur_id ? Number(newPlat.investisseur_id) : null,
date_ouverture: newPlat.date_ouverture || null,
type_pret_defaut: newPlat.type_pret_defaut || null,
freq_interets_defaut: newPlat.freq_interets_defaut || null,
referentiel_id: newPlat.referentiel_id ? Number(newPlat.referentiel_id) : null,
});
if (newPlatLogoFile) await uploadLogo(created.id, newPlatLogoFile);
setNewPlat(EMPTY_PLAT);
setNewPlatLogoFile(null);
setNewPlatLogoPreview(null);
setShowNewPlat(false);
await load();
} catch (e) { setErr(e.message); }
};
const delPlat = (id) => {
setConfirmDelete({
message: 'Supprimer cette plateforme ?',
onConfirm: async () => {
try {
await api.del(`/plateformes/${id}`);
setSelectedPlat(null);
await load();
} catch (e) { setErr(e.message); }
finally { setConfirmDelete(null); }
},
});
};
// ── Export/Import ZIP plateformes ─────────────────────────────────────
const handlePlatExportAll = async () => {
try {
setPlatExporting(true);
const blob = await api.blob('/plateformes/export');
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `plateformes-${new Date().toISOString().slice(0, 10)}.zip`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) { setPlatImportResult({ ok: false, msg: e.message }); }
finally { setPlatExporting(false); }
};
const handlePlatExportOne = async (plat) => {
try {
const blob = await api.blob(`/plateformes/${plat.id}/export`);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${plat.nom.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${new Date().toISOString().slice(0, 10)}.zip`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) { setPlatImportResult({ ok: false, msg: e.message }); }
};
const handlePlatImportZip = async (file) => {
try {
const fd = new FormData();
fd.append('file', file);
const r = await api.upload('/plateformes/import-zip', fd);
setPlatImportResult({ ok: true, msg: `Import terminé : ${r.created} créée(s), ${r.updated} mise(s) à jour sur ${r.total} entrée(s).` });
load();
} catch (e) { setPlatImportResult({ ok: false, msg: e.message }); }
};
const openEditPlat = async (p) => {
setEditPlatLogoFile(null);
setEditPlatLogoPreview(null);
// Charger les associations catégories/secteurs d'investissement de la plateforme
const [platCatsInv, platSectsInv] = await Promise.all([
api.get(`/plateformes/${p.id}/categories-inv`).catch(() => []),
api.get(`/plateformes/${p.id}/secteurs-inv`).catch(() => []),
]);
setEditPlat({
id: p.id, nom: p.nom, url: p.url || '',
categories_inv_ids: platCatsInv.map(c => c.id),
secteurs_inv_ids: platSectsInv.map(s => s.id),
inherited_cat_ids: platCatsInv.filter(c => c.is_inherited).map(c => c.id),
inherited_sect_ids: platSectsInv.filter(s => s.is_inherited).map(s => s.id),
domiciliation: p.domiciliation || 'france',
fiscalite: p.fiscalite || 'flat_tax',
taux_fiscalite_locale: p.taux_fiscalite_locale ?? '',
type_produit_fiscal: p.type_produit_fiscal || '2TT',
methode_remboursement: p.methode_remboursement || 'portefeuille',
investisseur_id: p.investisseur_id ?? null,
date_ouverture: p.date_ouverture || '',
logo_filename: p.logo_filename || null,
type_pret_defaut: p.type_pret_defaut || '',
freq_interets_defaut: p.freq_interets_defaut || '',
referentiel_id: p.referentiel_id ?? null,
referentiel_nom: p.referentiel_nom ?? null,
overridden_fields: p.overridden_fields ?? [],
});
};
const saveEditPlat = async (e) => {
e.preventDefault(); setErr(null); setMsg(null);
try {
await api.put(`/plateformes/${editPlat.id}`, {
nom: editPlat.nom, url: editPlat.url || '',
domiciliation: editPlat.domiciliation,
fiscalite: editPlat.fiscalite,
taux_fiscalite_locale: editPlat.fiscalite === 'avec_fiscalite_locale' && editPlat.taux_fiscalite_locale !== ''
? Number(editPlat.taux_fiscalite_locale) : null,
type_produit_fiscal: editPlat.type_produit_fiscal || '2TT',
methode_remboursement: editPlat.methode_remboursement || 'portefeuille',
investisseur_id: editPlat.investisseur_id ? Number(editPlat.investisseur_id) : null,
date_ouverture: editPlat.date_ouverture || null,
type_pret_defaut: editPlat.type_pret_defaut || null,
freq_interets_defaut: editPlat.freq_interets_defaut || null,
});
if (editPlatLogoFile) await uploadLogo(editPlat.id, editPlatLogoFile);
// Sauvegarder les associations catégories/secteurs d'investissement
await Promise.all([
api.put(`/plateformes/${editPlat.id}/categories-inv`, { ids: editPlat.categories_inv_ids || [] }),
api.put(`/plateformes/${editPlat.id}/secteurs-inv`, { ids: editPlat.secteurs_inv_ids || [] }),
]);
setMsg('Plateforme mise à jour.');
setTimeout(() => setMsg(null), 3000);
setEditPlatLogoFile(null);
setEditPlatLogoPreview(null);
setEditPlat(null);
await load();
} catch (e) { setErr(e.message); }
};
const delLogoPlat = async (id) => {
try {
await api.del(`/plateformes/${id}/logo`);
setEditPlat(ep => ep ? { ...ep, logo_filename: null } : ep);
await load();
} catch (e) { setErr(e.message); }
};
/* ── PFU CRUD ────────────────────────────────────────────────── */
return (
<>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
{msg && <div className="success-msg" style={{ marginBottom: 12 }}>{msg}</div>}
<div className="dr-mouvements-layout">
{/* Colonne gauche — liste */}
<div className="dr-mouvements-list">
<div className="card" style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div>
<h3 style={{ margin: 0 }}>Mes plateformes</h3>
<p className="text-muted" style={{ margin: '4px 0 0', fontSize: 'var(--fs-sm)' }}>
Plateformes de crowdlending que vous utilisez.
</p>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<ExportDropdown
disabled={plats.length === 0}
onCSV={() => dlBlob(platsToCSV(plats), 'plateformes.csv', 'text/csv;charset=utf-8')}
onXLS={() => dlBlob(platsToXLS(plats), 'plateformes.xls', 'application/vnd.ms-excel')}
/>
<button onClick={handlePlatExportAll} disabled={platExporting || plats.length === 0}
title="Exporter toutes les plateformes en ZIP"
style={{ padding: '7px 12px', borderRadius: 6, border: '1px solid var(--border)', fontSize: 13, fontWeight: 600, cursor: 'pointer', background: 'var(--surface-2)', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 5 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
{platExporting ? '…' : 'Export ZIP'}
</button>
<button onClick={() => platImportRef.current?.click()}
title="Importer un fichier ZIP de plateformes"
style={{ padding: '7px 12px', borderRadius: 6, border: '1px solid var(--border)', fontSize: 13, fontWeight: 600, cursor: 'pointer', background: 'var(--surface-2)', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 5 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Import ZIP
</button>
<input ref={platImportRef} type="file" accept=".zip" style={{ display: 'none' }}
onChange={e => { const f = e.target.files[0]; e.target.value = ''; if (f) handlePlatImportZip(f); }} />
<button className="primary" type="button"
onClick={() => { setNewPlat(EMPTY_PLAT); setErr(null); setShowNewPlat(true); }}>
+ Ajouter
</button>
</div>
</div>
{platImportResult && <ResultBanner result={platImportResult} onDismiss={() => setPlatImportResult(null)} style={{ marginBottom: 12 }} />}
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
<th style={{ padding: '10px 8px', width: 40 }}></th>
<th style={{ padding: '10px 16px', textAlign: 'left' }}>Nom</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Domiciliation</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Catégories</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Secteurs</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Détenteur</th>
<th style={{ padding: '10px 16px', textAlign: 'center', width: 60 }}>Invest.</th>
<th style={{ padding: '10px 8px', width: 40 }}></th>
</tr>
</thead>
<tbody>
{plats.length === 0 && (
<tr><td colSpan={7} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune plateforme</td></tr>
)}
{plats.map((p, i) => {
const imgSrc = p.icone_filename ? logoUrl(p.icone_filename) : p.logo_filename ? logoUrl(p.logo_filename) : null;
const inv = platInvestisseur(p);
const cats = p.categories_inv || [];
const sects = p.secteurs_inv || [];
return (
<tr key={p.id}
onClick={() => setSelectedPlat(selectedPlat?.id === p.id ? null : p)}
style={{
borderBottom: i < plats.length - 1 ? '1px solid var(--border)' : 'none',
cursor: 'pointer',
background: selectedPlat?.id === p.id ? 'var(--surface-2)' : 'none',
}}
onMouseEnter={e => { if (selectedPlat?.id !== p.id) e.currentTarget.style.background = 'var(--surface-2)'; }}
onMouseLeave={e => { if (selectedPlat?.id !== p.id) e.currentTarget.style.background = 'none'; }}>
<td style={{ padding: '8px 8px 8px 16px', width: 40 }}>
{imgSrc
? <img src={imgSrc} alt="" style={{ width: 32, height: 32, objectFit: 'contain', borderRadius: 4, display: 'block' }} />
: <div style={{ width: 32, height: 32, borderRadius: 4, background: 'var(--surface-2)', border: '1px dashed var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-muted)', fontSize: 16 }}>×</div>
}
</td>
<td style={{ padding: '10px 16px' }}>
<div style={{ fontWeight: 600 }}>{p.nom}</div>
</td>
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}>
{p.domiciliation
? <span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<FlagIcon code={p.domiciliation} size={15} />{countryLabel(p.domiciliation)}
</span>
: '—'}
</td>
<td style={{ padding: '10px 8px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{cats.slice(0, 2).map(c => <span key={c.id} className="chip-cat" style={{ fontSize: 11 }}>{c.nom}</span>)}
{cats.length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{cats.length - 2}</span>}
{cats.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}></span>}
</div>
</td>
<td style={{ padding: '10px 8px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{sects.slice(0, 2).map(s => <span key={s.id} className="chip-sect" style={{ fontSize: 11 }}>{s.nom}</span>)}
{sects.length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{sects.length - 2}</span>}
{sects.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}></span>}
</div>
</td>
<td style={{ padding: '10px 8px', fontSize: 12, color: 'var(--text-muted)' }}>
{inv ? memberLabel(inv) : '—'}
</td>
<td style={{ padding: '10px 16px', textAlign: 'center', fontWeight: 600,
color: (p.nb_investissements ?? 0) > 0 ? 'var(--text)' : 'var(--text-muted)' }}>
{p.nb_investissements ?? 0}
</td>
<td style={{ padding: '10px 8px', textAlign: 'right' }}>
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 4, fontSize: 18, color: 'var(--text-muted)', lineHeight: 1 }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={e => { e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); setPlatOpenMenu({ plat: p, x: rect.right, y: rect.bottom }); }}
></button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
{/* Colonne droite — détail */}
<div className="dr-mouvements-detail">
<PlatDetailPanel
plat={selectedPlat}
onEdit={openEditPlat}
/>
</div>
</div>
{/* Menu ⋮ plateformes */}
{platOpenMenu && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setPlatOpenMenu(null)} />
<div style={{ position: 'fixed', left: platOpenMenu.x, top: platOpenMenu.y,
transform: 'translateX(-100%) translateY(4px)', zIndex: 300,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)', padding: '4px 0', minWidth: 170 }}>
{[
{ icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>, label: 'Modifier',
onClick: () => { const p = platOpenMenu.plat; setPlatOpenMenu(null); openEditPlat(p); } },
{ icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>, label: 'Exporter',
onClick: () => { const p = platOpenMenu.plat; setPlatOpenMenu(null); handlePlatExportOne(p); } },
{ icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>, label: 'Supprimer',
onClick: () => { const p = platOpenMenu.plat; setPlatOpenMenu(null); delPlat(p.id); }, color: 'var(--danger)' },
].map(({ icon, label, onClick, color }) => (
<button key={label}
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px',
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 'var(--fs-sm)', color: color || 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={onClick}>
<span style={{ opacity: 0.7, flexShrink: 0, display: 'flex' }}>{icon}</span>
{label}
</button>
))}
</div>
</>
)}
<ConfirmModal
open={!!confirmDelete}
title="Supprimer la plateforme"
message={confirmDelete?.message}
onConfirm={confirmDelete?.onConfirm}
onCancel={() => setConfirmDelete(null)}
/>
</>
);
}
@@ -0,0 +1,196 @@
import { useState, useEffect } from 'react';
import { api } from '../../api.js';
export default function SecteursInvSection() {
const [secteursInv, setSecteursInv] = useState([]);
const [err, setErr] = useState(null);
const [selectedSectInv, setSelectedSectInv] = useState(null);
const [editingSectInv, setEditingSectInv] = useState(null);
const [editingNomSectInv, setEditingNomSectInv] = useState('');
const [newSectInvNom, setNewSectInvNom] = useState('');
const [showNewSectInv, setShowNewSectInv] = useState(false);
const [sectGlobalOpen, setSectGlobalOpen] = useState(false);
const [sectPrivateOpen, setSectPrivateOpen] = useState(true);
useEffect(() => {
api.get('/secteurs-inv').then(data => {
setSecteursInv(data.sort((a, b) => b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
}).catch(() => {});
}, []);
const saveSectInv = async (nom) => {
if (!nom.trim()) return;
try {
const row = await api.post('/secteurs-inv', { nom: nom.trim() });
setSecteursInv(prev => [...prev, row].sort((a, b) =>
b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
setShowNewSectInv(false); setNewSectInvNom(''); setErr(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const renameSectInv = async (id, nom) => {
if (!nom.trim()) return;
try {
await api.put(`/secteurs-inv/${id}`, { nom: nom.trim() });
setSecteursInv(prev => prev.map(s => s.id === id ? { ...s, nom: nom.trim() } : s));
setEditingSectInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const delSectInv = async (id) => {
try {
await api.del(`/secteurs-inv/${id}`);
setSecteursInv(prev => prev.filter(s => s.id !== id));
if (selectedSectInv?.id === id) setSelectedSectInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const globalSects = secteursInv.filter(s => s.is_global);
const privateSects = secteursInv.filter(s => !s.is_global);
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<h3 style={{ margin: 0 }}>Mes secteurs d'investissement</h3>
</div>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
{/* Accordéon — Secteurs globalement définis */}
<div className="card" style={{ marginBottom: 10 }}>
<button type="button"
onClick={() => setSectGlobalOpen(o => !o)}
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--text)' }}>
<span style={{ fontWeight: 600, fontSize: 'var(--fs-base)' }}>
Secteurs globalement définis
<span style={{ marginLeft: 8, fontWeight: 400, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
({globalSects.length})
</span>
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
style={{ transform: sectGlobalOpen ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{sectGlobalOpen && (
<div style={{ marginTop: 14 }}>
<table>
<thead>
<tr>
<th>Nom</th>
<th className="num">Plateformes</th>
<th className="num">Investissements</th>
</tr>
</thead>
<tbody>
{globalSects.length === 0 && (
<tr><td colSpan={3} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucun secteur global</td></tr>
)}
{globalSects.map(s => (
<tr key={s.id}>
<td><span style={{ fontWeight: 600 }}>{s.nom}</span></td>
<td className="num">{s.nb_plateformes > 0 ? s.nb_plateformes : <span className="text-muted">—</span>}</td>
<td className="num">{s.nb_investissements > 0 ? s.nb_investissements : <span className="text-muted">—</span>}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Accordéon — Mes propres secteurs */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<button type="button"
onClick={() => setSectPrivateOpen(o => !o)}
style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 8,
background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--text)', textAlign: 'left' }}>
<span style={{ fontWeight: 600, fontSize: 'var(--fs-base)' }}>
Mes propres secteurs
<span style={{ marginLeft: 8, fontWeight: 400, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
({privateSects.length})
</span>
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
style={{ transform: sectPrivateOpen ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{sectPrivateOpen && (
<button className="primary" type="button" style={{ marginLeft: 12, flexShrink: 0 }}
onClick={() => { setSectPrivateOpen(true); setShowNewSectInv(true); setNewSectInvNom(''); setErr(null); }}>
+ Ajouter
</button>
)}
</div>
{sectPrivateOpen && (
<div style={{ marginTop: 14 }}>
{showNewSectInv && (
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<input autoFocus style={{ flex: 1 }} placeholder="Nom du secteur"
value={newSectInvNom} onChange={e => setNewSectInvNom(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') saveSectInv(newSectInvNom); if (e.key === 'Escape') setShowNewSectInv(false); }} />
<button className="primary" onClick={() => saveSectInv(newSectInvNom)}>Créer</button>
<button onClick={() => setShowNewSectInv(false)}>Annuler</button>
</div>
)}
<table>
<thead>
<tr>
<th>Nom</th>
<th className="num">Plateformes</th>
<th className="num">Investissements</th>
<th style={{ width: 80 }}></th>
</tr>
</thead>
<tbody>
{privateSects.length === 0 && (
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucun secteur personnel</td></tr>
)}
{privateSects.map(s => (
<tr key={s.id}>
<td>
{editingSectInv === s.id ? (
<div style={{ display: 'flex', gap: 6 }}>
<input autoFocus style={{ flex: 1 }} value={editingNomSectInv}
onChange={e => setEditingNomSectInv(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') renameSectInv(s.id, editingNomSectInv); if (e.key === 'Escape') setEditingSectInv(null); }} />
<button className="primary" onClick={() => renameSectInv(s.id, editingNomSectInv)}>OK</button>
<button onClick={() => setEditingSectInv(null)}>✕</button>
</div>
) : (
<span style={{ fontWeight: 600 }}>{s.nom}</span>
)}
</td>
<td className="num">{s.nb_plateformes > 0 ? s.nb_plateformes : <span className="text-muted">—</span>}</td>
<td className="num">{s.nb_investissements > 0 ? s.nb_investissements : <span className="text-muted">—</span>}</td>
<td>
{editingSectInv !== s.id && (
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
<button style={{ fontSize: 12, padding: '2px 8px' }}
onClick={() => { setEditingSectInv(s.id); setEditingNomSectInv(s.nom); setErr(null); }}>
Renommer
</button>
<button style={{ fontSize: 12, padding: '2px 8px', color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => setConfirmDelete({
title: 'Supprimer le secteur',
message: `Supprimer le secteur "${s.nom}" ? Il sera retiré de toutes les plateformes et investissements.`,
confirmLabel: 'Supprimer',
onConfirm: () => { delSectInv(s.id); setConfirmDelete(null); }
})}>
Supprimer
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}