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